mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-05-14 17:25:18 +00:00
Compare commits
100 Commits
e03e740149
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 39a41e561c | |||
| 4424db22cb | |||
| 28a796e24f | |||
| f3c15e3e1c | |||
| 3301f8d95f | |||
| a575fddaa2 | |||
| c9fe7d8583 | |||
| 03f17a6e05 | |||
| 3132aab2a5 | |||
| 3d1db1f8ba | |||
| 58872ced81 | |||
| 13b3af3330 | |||
| eca7084f4e | |||
| 6861b2c0eb | |||
| 66b1f0d029 | |||
| a29a0b1862 | |||
| 252b50d2a7 | |||
| 2c61da9fc3 | |||
| 2d8185b747 | |||
| a47a5babce | |||
| 3f6c90cc3c | |||
| 69c4f15ce7 | |||
| 56c1b0d0cd | |||
| 91e9caea48 | |||
| feb6af28ef | |||
| f8c2b4236b | |||
| dc2626e020 | |||
| 46b0b744ca | |||
| 5f2e7ef696 | |||
| 152a85bfb8 | |||
| fdfe301868 | |||
| cbfe1ed8ae | |||
| 9470162236 | |||
| 6a57fa1e00 | |||
| ab67fc0b29 | |||
| e18566d801 | |||
| 7bc0f32145 | |||
| 6ed3e60dd0 | |||
| ab8ea0dbd6 | |||
| b0446dcd29 | |||
| 55d309b2d7 | |||
| d99a8c8452 | |||
| 3f6a195ecb | |||
| 430ea4a120 | |||
| cc0fc9b77f | |||
| 9ada9acb3a | |||
| 246ef1b059 | |||
| 6572a39d48 | |||
| a80262c0d4 | |||
| 531c2295bd | |||
| 0640ec6439 | |||
| a7eb14046f | |||
| 539580ad09 | |||
| faf5bd1e8c | |||
| 97378422bd | |||
| 2632c21de3 | |||
| 64db9a4e6a | |||
| d0f8d7d172 | |||
| 20b6c731b8 | |||
| 2f63009c31 | |||
| f0d4206731 | |||
| b8aad8b695 | |||
| 697696347f | |||
| d6389157ec | |||
| 25dbc3f331 | |||
| bb8799eb8a | |||
| 86fd72b623 | |||
| 9c24a8658f | |||
| 5fc19f6ccb | |||
| 35bfeeb51e | |||
| dfbc840c69 | |||
| 1bea9703ea | |||
| 4d68ed2a24 | |||
| a0c7a7e8ca | |||
| 3ec92ff853 | |||
| 8cb2f578df | |||
| 412a7bae16 | |||
| 8e280de139 | |||
| 19f47a82fa | |||
| 3b4dc298f8 | |||
| 79e10e97b7 | |||
| f5a9838474 | |||
| 242d1b9948 | |||
| 3db9872791 | |||
| 6a0db00f24 | |||
| 3529749df5 | |||
| ae775916b0 | |||
| 45969feaed | |||
| 464d307ee8 | |||
| 4aceb2ed62 | |||
| a8a2efd091 | |||
| 3284684282 | |||
| 20c4a4809b | |||
| 898f7479c9 | |||
| 56513230e4 | |||
| c35f44baef | |||
| ef7059e748 | |||
| 6597fb2862 | |||
| 6ba6b2ea99 | |||
| 94b4e1f883 |
3
.claude/.gitignore
vendored
Normal file
3
.claude/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!.settings.json
|
||||||
52
.claude/settings.json
Normal file
52
.claude/settings.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Read",
|
||||||
|
"Edit",
|
||||||
|
"Write",
|
||||||
|
"Bash(*)",
|
||||||
|
"Read(//tmp/**)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"WebFetch(domain:api.github.com)",
|
||||||
|
"WebFetch(domain:docs.docker.com)",
|
||||||
|
"WebFetch(domain:pypi.org)",
|
||||||
|
"WebFetch(domain:docs.cypress.io)",
|
||||||
|
"WebFetch(domain:flask.palletsprojects.com)",
|
||||||
|
"Skill(update-config)",
|
||||||
|
"Skill(update-config:*)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"Bash(rm -rf*)",
|
||||||
|
"Bash(sudo*)"
|
||||||
|
],
|
||||||
|
"ask": [
|
||||||
|
"Bash(git push*)",
|
||||||
|
"Bash(docker run*)",
|
||||||
|
"Bash(curl*)"
|
||||||
|
],
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/tmp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"autoAllowBashIfSandboxed": true,
|
||||||
|
"filesystem": {
|
||||||
|
"allowWrite": [
|
||||||
|
".",
|
||||||
|
"/tmp"
|
||||||
|
],
|
||||||
|
"denyRead": [
|
||||||
|
"~/.ssh",
|
||||||
|
"~/.gnupg",
|
||||||
|
"~/.kube",
|
||||||
|
"~/.aws",
|
||||||
|
"~/.config/gcloud"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.github/FUNDING.yml
vendored
Normal file
7
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
github: kevinveenbirkenbach
|
||||||
|
|
||||||
|
patreon: kevinveenbirkenbach
|
||||||
|
|
||||||
|
buy_me_a_coffee: kevinveenbirkenbach
|
||||||
|
|
||||||
|
custom: https://s.veen.world/paypaldonate
|
||||||
90
.github/workflows/ci.yml
vendored
Normal file
90
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
tags-ignore:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
name: Run security workflow
|
||||||
|
uses: ./.github/workflows/security.yml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
tests:
|
||||||
|
name: Run test workflow
|
||||||
|
uses: ./.github/workflows/tests.yml
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Run lint workflow
|
||||||
|
uses: ./.github/workflows/lint.yml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- security
|
||||||
|
- tests
|
||||||
|
- lint
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect semver tag on current commit
|
||||||
|
id: semver
|
||||||
|
run: |
|
||||||
|
SEMVER_TAG="$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)"
|
||||||
|
if [ -n "$SEMVER_TAG" ]; then
|
||||||
|
{
|
||||||
|
echo "found=true"
|
||||||
|
echo "raw_tag=$SEMVER_TAG"
|
||||||
|
echo "version=${SEMVER_TAG#v}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compute image name
|
||||||
|
if: steps.semver.outputs.found == 'true'
|
||||||
|
id: image
|
||||||
|
run: echo "name=ghcr.io/$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.semver.outputs.found == 'true'
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
if: steps.semver.outputs.found == 'true'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and publish image
|
||||||
|
if: steps.semver.outputs.found == 'true'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.image.outputs.name }}:${{ steps.semver.outputs.version }}
|
||||||
77
.github/workflows/lint.yml
vendored
Normal file
77
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-actions:
|
||||||
|
name: Lint GitHub Actions
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Run actionlint
|
||||||
|
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
|
||||||
|
|
||||||
|
lint-python:
|
||||||
|
name: Lint Python
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install lint dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install ".[dev]"
|
||||||
|
|
||||||
|
- name: Ruff lint
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Ruff format check
|
||||||
|
run: ruff format --check .
|
||||||
|
|
||||||
|
lint-docker:
|
||||||
|
name: Lint Dockerfile
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Run hadolint
|
||||||
|
id: hadolint
|
||||||
|
continue-on-error: true
|
||||||
|
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
|
||||||
|
with:
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
format: sarif
|
||||||
|
output-file: hadolint-results.sarif
|
||||||
|
failure-threshold: warning
|
||||||
|
|
||||||
|
- name: Upload hadolint SARIF
|
||||||
|
if: always() && github.event_name == 'push'
|
||||||
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
|
with:
|
||||||
|
sarif_file: hadolint-results.sarif
|
||||||
|
wait-for-processing: true
|
||||||
|
category: hadolint
|
||||||
|
|
||||||
|
- name: Fail on hadolint warnings
|
||||||
|
if: always()
|
||||||
|
run: python3 utils/check_hadolint_sarif.py hadolint-results.sarif
|
||||||
48
.github/workflows/security.yml
vendored
Normal file
48
.github/workflows/security.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Run security scan
|
||||||
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
security-events: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: actions
|
||||||
|
build-mode: none
|
||||||
|
- language: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
- language: python
|
||||||
|
build-mode: none
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
- name: Run manual build steps
|
||||||
|
if: matrix.build-mode == 'manual'
|
||||||
|
run: |
|
||||||
|
echo "No manual build is configured for this repository."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
category: /language:${{ matrix.language }}
|
||||||
194
.github/workflows/tests.yml
vendored
Normal file
194
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-lint:
|
||||||
|
name: Run lint tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Run lint test suite
|
||||||
|
run: python -m unittest discover -s tests/lint -t .
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
name: Run integration tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install integration test dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed .
|
||||||
|
|
||||||
|
- name: Run integration test suite
|
||||||
|
run: python -m unittest discover -s tests/integration -t .
|
||||||
|
|
||||||
|
test-unit:
|
||||||
|
name: Run unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install unit test dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed .
|
||||||
|
|
||||||
|
- name: Run unit test suite
|
||||||
|
run: python -m unittest discover -s tests/unit -t .
|
||||||
|
|
||||||
|
security-python:
|
||||||
|
name: Run Python security checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install security dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed ".[dev]"
|
||||||
|
|
||||||
|
- name: Run Bandit
|
||||||
|
run: python -m bandit -q -ll -ii -r app main.py
|
||||||
|
|
||||||
|
- name: Export runtime requirements
|
||||||
|
run: python utils/export_runtime_requirements.py > runtime-requirements.txt
|
||||||
|
|
||||||
|
- name: Audit Python runtime dependencies
|
||||||
|
run: python -m pip_audit -r runtime-requirements.txt
|
||||||
|
|
||||||
|
test-security:
|
||||||
|
name: Run security guardrail tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install security test dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed .
|
||||||
|
|
||||||
|
- name: Run security test suite
|
||||||
|
run: python -m unittest discover -s tests/security -t .
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: Run end-to-end tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- test-lint
|
||||||
|
- test-unit
|
||||||
|
- test-integration
|
||||||
|
- security-python
|
||||||
|
- test-security
|
||||||
|
env:
|
||||||
|
FLASK_HOST: "127.0.0.1"
|
||||||
|
FLASK_PORT: "5001"
|
||||||
|
PORT: "5001"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed .
|
||||||
|
|
||||||
|
- name: Prepare app config for CI
|
||||||
|
run: cp app/config.sample.yaml app/config.yaml
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: app/package.json
|
||||||
|
|
||||||
|
- name: Install Node dependencies
|
||||||
|
working-directory: app
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Install Cypress system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2t64 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatspi2.0-0t64 \
|
||||||
|
libcups2t64 \
|
||||||
|
libdrm2 \
|
||||||
|
libgbm1 \
|
||||||
|
libglib2.0-0t64 \
|
||||||
|
libgtk-3-0t64 \
|
||||||
|
libnotify4 \
|
||||||
|
libnspr4 \
|
||||||
|
libnss3 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxss1 \
|
||||||
|
libxtst6 \
|
||||||
|
xauth \
|
||||||
|
xvfb
|
||||||
|
|
||||||
|
- name: Run Cypress tests
|
||||||
|
uses: cypress-io/github-action@v6
|
||||||
|
with:
|
||||||
|
working-directory: app
|
||||||
|
install: false
|
||||||
|
start: python app.py
|
||||||
|
wait-on: http://127.0.0.1:5001
|
||||||
|
wait-on-timeout: 120
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,3 +1,12 @@
|
|||||||
app/config.yaml
|
app/config.yaml
|
||||||
*__pycache__*
|
*__pycache__*
|
||||||
app/static/cache/*
|
app/static/cache/*
|
||||||
|
.env
|
||||||
|
app/cypress/screenshots/*
|
||||||
|
.ruff_cache/
|
||||||
|
app/node_modules/
|
||||||
|
app/static/vendor/
|
||||||
|
hadolint-results.sarif
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
app/core.*
|
||||||
|
|||||||
13
AGENTS.md
Normal file
13
AGENTS.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Pre-Commit Validation
|
||||||
|
|
||||||
|
- You MUST run `make test` before every commit whenever the staged change includes at least one file that is not `.md` or `.rst`, unless explicitly instructed otherwise.
|
||||||
|
- You MUST commit only after all tests pass.
|
||||||
|
- You MUST NOT commit automatically without explicit confirmation from the user.
|
||||||
|
|
||||||
|
## Vendor Assets
|
||||||
|
|
||||||
|
- Browser vendor assets (Bootstrap, Font Awesome, etc.) are managed via npm.
|
||||||
|
- Run `npm install` inside `app/` to populate `app/static/vendor/` before starting the dev server or running e2e tests.
|
||||||
|
- Never commit `app/node_modules/` or `app/static/vendor/` — both are gitignored and generated at build time.
|
||||||
23
CHANGELOG.md
Normal file
23
CHANGELOG.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
## [1.2.0] - 2026-05-11
|
||||||
|
|
||||||
|
* * Navigation behavior: Top-level dropdowns now open reliably on hover and click via Bootstrap, escape the header and navbar overflow clips, and flip between downward and upward based on whether more space is above or below the toggle
|
||||||
|
* Compose-driven dependencies: docker-compose runs npm install inside the container on every up and persists node_modules plus static/vendor in named volumes, removing the host-side npm-install step from up, dev, prod, and test-e2e
|
||||||
|
* Test coverage: New Cypress specs cover both header and footer dropdown directions, with a Jinja unit test guarding the data-bs-toggle attribute on top-level dropdown toggles
|
||||||
|
* Harness configuration: Enabled the Claude Code sandbox with scoped filesystem rules, consolidated the bash allowlist behind a single wildcard, and gitignored local-only state under .claude
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-03-30
|
||||||
|
|
||||||
|
* *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads
|
||||||
|
* *Modern Python packaging*: Migration to pyproject.toml and updated Dockerfile using Python 3.12
|
||||||
|
* *Improved test coverage*: Added unit, integration, lint, security, and E2E tests using act
|
||||||
|
* *Local vendor assets*: Replaced external CDNs with npm-based local asset pipeline
|
||||||
|
* *Enhanced build workflow*: Extended Makefile with targets for test, lint, security, and CI plus vendor build process
|
||||||
|
* *Frontend fix*: Prevented navbar wrapping and improved layout behavior
|
||||||
|
* *Developer guidelines*: Introduced AGENTS.md and CLAUDE.md with enforced pre-commit rules
|
||||||
|
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-02-19
|
||||||
|
|
||||||
|
* Official Release🥳
|
||||||
|
|
||||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Startup
|
||||||
|
|
||||||
|
You MUST read `AGENTS.md` and follow all instructions in it at the start of every conversation before doing anything else.
|
||||||
28
Dockerfile
28
Dockerfile
@@ -1,18 +1,20 @@
|
|||||||
# Basis-Image für Python
|
FROM python:3.12-slim
|
||||||
FROM python:slim
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
FLASK_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# hadolint ignore=DL3008
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /tmp/build
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md main.py ./
|
||||||
|
COPY app ./app
|
||||||
|
RUN python -m pip install --no-cache-dir .
|
||||||
|
|
||||||
# Arbeitsverzeichnis festlegen
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Abhängigkeiten kopieren und installieren
|
|
||||||
COPY app/requirements.txt requirements.txt
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Anwendungscode kopieren
|
|
||||||
COPY app/ .
|
COPY app/ .
|
||||||
|
RUN npm install --prefix /app
|
||||||
|
|
||||||
# Port freigeben
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
# Startbefehl
|
|
||||||
CMD ["python", "app.py"]
|
CMD ["python", "app.py"]
|
||||||
|
|||||||
4
MIRRORS
Normal file
4
MIRRORS
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
git@github.com:kevinveenbirkenbach/port-ui.git
|
||||||
|
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/port-ui.git
|
||||||
|
ssh://git@git.veen.world:2201/kevinveenbirkenbach/port-ui.git
|
||||||
|
|
||||||
169
Makefile
Normal file
169
Makefile
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Load environment variables from .env
|
||||||
|
ifneq (,$(wildcard .env))
|
||||||
|
include .env
|
||||||
|
# Export variables defined in .env
|
||||||
|
export $(shell sed 's/=.*//' .env)
|
||||||
|
endif
|
||||||
|
|
||||||
|
# Default port (can be overridden with PORT env var)
|
||||||
|
PORT ?= 5000
|
||||||
|
PYTHON ?= python3
|
||||||
|
ACT ?= act
|
||||||
|
|
||||||
|
# Default port (can be overridden with PORT env var)
|
||||||
|
.PHONY: build
|
||||||
|
build:
|
||||||
|
# Build the Docker image.
|
||||||
|
docker build -t application-portfolio .
|
||||||
|
|
||||||
|
.PHONY: build-no-cache
|
||||||
|
build-no-cache:
|
||||||
|
# Build the Docker image without cache.
|
||||||
|
docker build --no-cache -t application-portfolio .
|
||||||
|
|
||||||
|
.PHONY: up
|
||||||
|
up:
|
||||||
|
# Start the application using docker-compose with build.
|
||||||
|
docker-compose up -d --build --force-recreate
|
||||||
|
|
||||||
|
.PHONY: down
|
||||||
|
down:
|
||||||
|
# Stop and remove the 'portfolio' container, ignore errors, and bring down compose.
|
||||||
|
- docker stop portfolio || true
|
||||||
|
- docker rm portfolio || true
|
||||||
|
- docker-compose down
|
||||||
|
|
||||||
|
.PHONY: run-dev
|
||||||
|
run-dev:
|
||||||
|
# Run the container in development mode (hot-reload).
|
||||||
|
docker run -d \
|
||||||
|
-p $(PORT):$(PORT) \
|
||||||
|
--name portfolio \
|
||||||
|
-v $(PWD)/app/:/app \
|
||||||
|
-e FLASK_APP=app.py \
|
||||||
|
-e FLASK_ENV=development \
|
||||||
|
application-portfolio
|
||||||
|
|
||||||
|
.PHONY: run-prod
|
||||||
|
run-prod:
|
||||||
|
# Run the container in production mode.
|
||||||
|
docker run -d \
|
||||||
|
-p $(PORT):$(PORT) \
|
||||||
|
--name portfolio \
|
||||||
|
application-portfolio
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs:
|
||||||
|
# Display the logs of the 'portfolio' container.
|
||||||
|
docker logs -f portfolio
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev:
|
||||||
|
# Start the application in development mode using docker-compose.
|
||||||
|
FLASK_ENV=development docker-compose up -d
|
||||||
|
|
||||||
|
.PHONY: prod
|
||||||
|
prod:
|
||||||
|
# Start the application in production mode using docker-compose (with build).
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
.PHONY: cleanup
|
||||||
|
cleanup:
|
||||||
|
# Remove all stopped Docker containers to reclaim space.
|
||||||
|
docker container prune -f
|
||||||
|
|
||||||
|
.PHONY: delete
|
||||||
|
delete:
|
||||||
|
# Force remove the 'portfolio' container if it exists.
|
||||||
|
- docker rm -f portfolio
|
||||||
|
|
||||||
|
.PHONY: browse
|
||||||
|
browse:
|
||||||
|
# Open the application in the browser at http://localhost:$(PORT)
|
||||||
|
chromium http://localhost:$(PORT)
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install:
|
||||||
|
# Install runtime Python dependencies from pyproject.toml.
|
||||||
|
$(PYTHON) -m pip install -e .
|
||||||
|
|
||||||
|
.PHONY: install-dev
|
||||||
|
install-dev:
|
||||||
|
# Install runtime and developer dependencies from pyproject.toml.
|
||||||
|
$(PYTHON) -m pip install -e ".[dev]"
|
||||||
|
|
||||||
|
.PHONY: lint-actions
|
||||||
|
lint-actions:
|
||||||
|
# Lint GitHub Actions workflows.
|
||||||
|
docker run --rm -v "$$PWD:/repo" -w /repo rhysd/actionlint:latest
|
||||||
|
|
||||||
|
.PHONY: lint-python
|
||||||
|
lint-python: install-dev
|
||||||
|
# Run Python lint and format checks.
|
||||||
|
$(PYTHON) -m ruff check .
|
||||||
|
$(PYTHON) -m ruff format --check .
|
||||||
|
|
||||||
|
.PHONY: lint-docker
|
||||||
|
lint-docker:
|
||||||
|
# Lint the Dockerfile.
|
||||||
|
docker run --rm -i hadolint/hadolint < Dockerfile
|
||||||
|
|
||||||
|
.PHONY: test-lint
|
||||||
|
test-lint:
|
||||||
|
# Run lint guardrail tests.
|
||||||
|
$(PYTHON) -m unittest discover -s tests/lint -t .
|
||||||
|
|
||||||
|
.PHONY: test-integration
|
||||||
|
test-integration: install
|
||||||
|
# Run repository integration tests.
|
||||||
|
$(PYTHON) -m unittest discover -s tests/integration -t .
|
||||||
|
|
||||||
|
.PHONY: test-unit
|
||||||
|
test-unit: install
|
||||||
|
# Run repository unit tests.
|
||||||
|
$(PYTHON) -m unittest discover -s tests/unit -t .
|
||||||
|
|
||||||
|
.PHONY: test-security
|
||||||
|
test-security: install
|
||||||
|
# Run repository security guardrail tests.
|
||||||
|
$(PYTHON) -m unittest discover -s tests/security -t .
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint: lint-actions lint-python lint-docker test-lint
|
||||||
|
# Run the full lint suite.
|
||||||
|
|
||||||
|
.PHONY: security
|
||||||
|
security: install-dev test-security
|
||||||
|
# Run security checks.
|
||||||
|
$(PYTHON) -m bandit -q -ll -ii -r app main.py
|
||||||
|
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
|
||||||
|
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
|
||||||
|
|
||||||
|
.PHONY: test-e2e
|
||||||
|
test-e2e:
|
||||||
|
# Run Cypress end-to-end tests via act (stop portfolio container to free port first).
|
||||||
|
-docker stop portfolio 2>/dev/null || true
|
||||||
|
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
|
||||||
|
-docker start portfolio 2>/dev/null || true
|
||||||
|
|
||||||
|
.PHONY: test-workflow
|
||||||
|
test-workflow:
|
||||||
|
# Run the GitHub test workflow locally via act.
|
||||||
|
$(ACT) workflow_dispatch -W .github/workflows/tests.yml
|
||||||
|
|
||||||
|
.PHONY: lint-workflow
|
||||||
|
lint-workflow:
|
||||||
|
# Run the GitHub lint workflow locally via act.
|
||||||
|
$(ACT) workflow_dispatch -W .github/workflows/lint.yml
|
||||||
|
|
||||||
|
.PHONY: quality
|
||||||
|
quality: lint-workflow test-workflow
|
||||||
|
# Run the GitHub lint and test workflows locally via act.
|
||||||
|
|
||||||
|
.PHONY: ci
|
||||||
|
ci: lint security test-unit test-integration test-e2e
|
||||||
|
# Run the local CI suite.
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ci
|
||||||
|
# Run the full validation suite.
|
||||||
206
README.md
206
README.md
@@ -1,50 +1,95 @@
|
|||||||
# Portfolio CMS: Flask-based Portfolio Management🚀
|
# PortUI 🖥️✨
|
||||||
|
|
||||||
This software allows individuals and institutions to set up an easy portfolio/landingpage/homepage to showcase their projects and online presence. It is highly customizable via a YAML configuration file.
|
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||||
|
|
||||||
## Features ✨
|
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
|
||||||
|
|
||||||
- **Dynamic Navigation**: Easily create dropdown menus and nested links.
|
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
|
||||||
- **Customizable Cards**: Showcase your skills, projects, or services.
|
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
|
||||||
- **Cache Management**: Optimize your assets with automatic caching.
|
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevin’s personal site)
|
||||||
- **Responsive Design**: Beautiful on any device with Bootstrap.
|
|
||||||
- **Easy Configuration**: Update content using a YAML file.
|
|
||||||
|
|
||||||
## Access 🌐
|
---
|
||||||
|
|
||||||
### Locale
|
## ✨ Key Features
|
||||||
Access the application locally at [http://127.0.0.1:5000](http://127.0.0.1:5000).
|
|
||||||
|
|
||||||
## Getting Started 🏁
|
- **Dynamic Navigation**
|
||||||
|
Create dropdowns & nested menus with ease.
|
||||||
|
- **Customizable Cards**
|
||||||
|
Highlight skills, projects, or services—with icons, titles, and links.
|
||||||
|
- **Smart Cache Management**
|
||||||
|
Auto-cache assets for lightning-fast loading.
|
||||||
|
- **Responsive Design**
|
||||||
|
Built on Bootstrap; looks great on desktop, tablet & mobile.
|
||||||
|
- **YAML-Driven**
|
||||||
|
All content & structure defined in a simple `config.yaml`.
|
||||||
|
- **CLI Control**
|
||||||
|
Manage Docker containers via the `portfolio` command.
|
||||||
|
|
||||||
### Prerequisites 📋
|
---
|
||||||
|
|
||||||
- Docker and Docker Compose installed on your system.
|
## 🌐 Quick Access
|
||||||
- Basic knowledge of Python and YAML for configuration.
|
|
||||||
|
|
||||||
### Installation 🛠️
|
- **Local Preview:**
|
||||||
|
[http://127.0.0.1:5000](http://127.0.0.1:5000)
|
||||||
|
|
||||||
1. **Clone the repository:**
|
---
|
||||||
|
|
||||||
|
## 🏁 Getting Started
|
||||||
|
|
||||||
|
### 🔧 Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Basic Python & YAML knowledge
|
||||||
|
|
||||||
|
### 🛠️ Installation via Git
|
||||||
|
|
||||||
|
1. **Clone & enter repo**
|
||||||
```bash
|
```bash
|
||||||
git clone <repository_url>
|
git clone <repository_url>
|
||||||
cd <repository_directory>
|
cd <repository_directory>
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Update the configuration:**
|
2. **Configure**
|
||||||
Create a `config.yaml` file. You can use `config.sample.yaml` as an example (see below for details on the configuration).
|
Copy `config.sample.yaml` → `config.yaml` & customize.
|
||||||
|
3. **Build & run**
|
||||||
|
|
||||||
3. **Build and run the Docker container:**
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build
|
docker-compose up --build
|
||||||
```
|
```
|
||||||
|
4. **Browse**
|
||||||
|
Open [http://localhost:5000](http://localhost:5000)
|
||||||
|
|
||||||
4. **Access your portfolio:** Open your browser and navigate to `http://localhost:5000`.
|
### 📦 Installation via Kevin’s Package Manager
|
||||||
|
|
||||||
## Configuration Guide 🔧
|
```bash
|
||||||
|
pkgmgr install portui
|
||||||
|
```
|
||||||
|
|
||||||
The portfolio is powered by a YAML configuration file (`config.yaml`). This file allows you to define the structure and content of your site, including cards, navigation, and company details.
|
Once installed, the `portui` CLI is available system-wide.
|
||||||
|
|
||||||
### YAML Configuration Example 📄
|
---
|
||||||
|
|
||||||
|
## 🖥️ CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
portui --help
|
||||||
|
```
|
||||||
|
|
||||||
|
* `build` Build the Docker image
|
||||||
|
* `up` Start containers (with build)
|
||||||
|
* `down` Stop & remove containers
|
||||||
|
* `run-dev` Dev mode (hot-reload)
|
||||||
|
* `run-prod` Production mode
|
||||||
|
* `logs` View container logs
|
||||||
|
* `dev` Docker-Compose dev environment
|
||||||
|
* `prod` Docker-Compose prod environment
|
||||||
|
* `cleanup` Prune stopped containers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 YAML Configuration Guide
|
||||||
|
|
||||||
|
Define your site’s structure in `config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
accounts:
|
accounts:
|
||||||
@@ -58,17 +103,12 @@ accounts:
|
|||||||
icon:
|
icon:
|
||||||
class: fas fa-newspaper
|
class: fas fa-newspaper
|
||||||
children:
|
children:
|
||||||
- name: Microblogs
|
- name: Mastodon
|
||||||
description: Stay updated with my microblog posts.
|
description: Follow me on Mastodon.
|
||||||
icon:
|
icon:
|
||||||
class: fa-solid fa-pen-nib
|
class: fa-brands fa-mastodon
|
||||||
children:
|
url: https://microblog.veen.world/@kevinveenbirkenbach
|
||||||
- name: Mastodon
|
identifier: "@kevinveenbirkenbach@microblog.veen.world"
|
||||||
description: Follow my updates on Mastodon.
|
|
||||||
icon:
|
|
||||||
class: fa-brands fa-mastodon
|
|
||||||
url: https://microblog.veen.world/@kevinveenbirkenbach
|
|
||||||
identifier: "@kevinveenbirkenbach@microblog.veen.world"
|
|
||||||
cards:
|
cards:
|
||||||
- icon:
|
- icon:
|
||||||
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
||||||
@@ -76,9 +116,10 @@ accounts:
|
|||||||
text: I lead agile transformations and improve team dynamics through Scrum and Agile Coaching.
|
text: I lead agile transformations and improve team dynamics through Scrum and Agile Coaching.
|
||||||
url: https://www.agile-coach.world
|
url: https://www.agile-coach.world
|
||||||
link_text: www.agile-coach.world
|
link_text: www.agile-coach.world
|
||||||
|
|
||||||
company:
|
company:
|
||||||
titel: Kevin Veen-Birkenbach
|
title: Kevin Veen-Birkenbach
|
||||||
subtitel: Consulting and Coaching Solutions
|
subtitle: Consulting & Coaching Solutions
|
||||||
logo:
|
logo:
|
||||||
source: https://cloud.veen.world/s/logo_face_512x512/download
|
source: https://cloud.veen.world/s/logo_face_512x512/download
|
||||||
favicon:
|
favicon:
|
||||||
@@ -91,92 +132,27 @@ company:
|
|||||||
imprint_url: https://s.veen.world/imprint
|
imprint_url: https://s.veen.world/imprint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Understanding the `children` Key 🔍
|
* **`children`** enables multi-level menus.
|
||||||
|
* **`link`** references other YAML paths to avoid duplication.
|
||||||
|
|
||||||
The `children` key allows hierarchical nesting of elements. Each child can itself have children, enabling the creation of multi-level navigation menus or grouped content. Example:
|
---
|
||||||
|
|
||||||
```yaml
|
## 🚢 Production Deployment
|
||||||
children:
|
|
||||||
- name: Parent Item
|
|
||||||
description: Parent description.
|
|
||||||
icon:
|
|
||||||
class: fa-solid fa-folder
|
|
||||||
children:
|
|
||||||
- name: Child Item
|
|
||||||
description: Child description.
|
|
||||||
icon:
|
|
||||||
class: fa-solid fa-file
|
|
||||||
url: https://example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
This structure will render a parent menu or section containing nested child elements. Each child can be further customized with icons, descriptions, and links.
|
* Use a reverse proxy (NGINX/Apache).
|
||||||
|
* Secure with SSL/TLS.
|
||||||
|
* Swap to a production database if needed.
|
||||||
|
|
||||||
### Understanding the `link` Key 🔗
|
---
|
||||||
|
|
||||||
The `link` key allows you to reference another part of the YAML configuration by its path. This is useful for avoiding duplication and maintaining consistency. Example:
|
## 📜 License
|
||||||
|
|
||||||
```yaml
|
Licensed under **GNU AGPLv3**. See [LICENSE](./LICENSE) for details.
|
||||||
children:
|
|
||||||
- name: Blog
|
|
||||||
description: My blog posts.
|
|
||||||
icon:
|
|
||||||
class: fa-solid fa-blog
|
|
||||||
url: https://example.com/blog
|
|
||||||
- name: Featured Blog
|
|
||||||
link: accounts.children[0].children[0] # References the "Blog" item above
|
|
||||||
```
|
|
||||||
|
|
||||||
In this example, `Featured Blog` will inherit all properties from the `Blog` item, including its name, description, and URL. This feature ensures that any updates to the `Blog` item are automatically reflected in all linked entries.
|
---
|
||||||
|
|
||||||
## Administrate Docker 🐳
|
## ✍️ Author
|
||||||
|
|
||||||
### Stop and Destroy
|
Created by [Kevin Veen-Birkenbach](https://www.veen.world/)
|
||||||
```bash
|
|
||||||
docker stop portfolio; docker rm portfolio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build
|
Enjoy building your portfolio! 🌟
|
||||||
```bash
|
|
||||||
docker build -t application-portfolio .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run
|
|
||||||
|
|
||||||
#### Run Development Environment
|
|
||||||
```bash
|
|
||||||
docker run -d -p 5000:5000 --name portfolio -v $(pwd)/app/:/app -e FLASK_APP=app.py -e FLASK_ENV=development application-portfolio
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run Production Environment
|
|
||||||
```bash
|
|
||||||
docker run -d -p 5000:5000 --name portfolio application-portfolio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debug
|
|
||||||
```bash
|
|
||||||
docker logs -f portfolio
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Mode 🧑💻
|
|
||||||
|
|
||||||
To run the app in development mode with hot-reloading:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
FLASK_ENV=development docker-compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment 🚢
|
|
||||||
|
|
||||||
For production deployment, ensure to:
|
|
||||||
|
|
||||||
- Use a reverse proxy like NGINX or Apache.
|
|
||||||
- Secure your site with SSL/TLS.
|
|
||||||
- Use a production-ready database if required.
|
|
||||||
|
|
||||||
## Author ✍️
|
|
||||||
|
|
||||||
This software was created by [Kevin Veen-Birkenbach](https://www.veen.world/).
|
|
||||||
|
|
||||||
## License 📜
|
|
||||||
|
|
||||||
This project is licensed under the GNU Affero General Public License Version 3. See the [LICENSE](./LICENSE) file for details.
|
|
||||||
|
|||||||
2
app/.gitignore
vendored
Normal file
2
app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Portfolio UI web application package."""
|
||||||
131
app/app.py
131
app/app.py
@@ -1,10 +1,26 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from flask import Flask, render_template
|
|
||||||
import requests
|
import requests
|
||||||
import hashlib
|
|
||||||
import yaml
|
import yaml
|
||||||
from utils.configuration_resolver import ConfigurationResolver
|
from flask import Flask, current_app, render_template
|
||||||
from utils.cache_manager import CacheManager
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.utils.cache_manager import CacheManager
|
||||||
|
from app.utils.compute_card_classes import compute_card_classes
|
||||||
|
from app.utils.configuration_resolver import ConfigurationResolver
|
||||||
|
except ImportError: # pragma: no cover - supports running from the app/ directory.
|
||||||
|
from utils.cache_manager import CacheManager
|
||||||
|
from utils.compute_card_classes import compute_card_classes
|
||||||
|
from utils.configuration_resolver import ConfigurationResolver
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
||||||
|
FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
|
||||||
|
FLASK_PORT = int(os.getenv("FLASK_PORT", os.getenv("PORT", 5000)))
|
||||||
|
print(f"Starting app on {FLASK_HOST}:{FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
|
||||||
|
|
||||||
# Initialize the CacheManager
|
# Initialize the CacheManager
|
||||||
cache_manager = CacheManager()
|
cache_manager = CacheManager()
|
||||||
@@ -12,39 +28,106 @@ cache_manager = CacheManager()
|
|||||||
# Clear cache on startup
|
# Clear cache on startup
|
||||||
cache_manager.clear_cache()
|
cache_manager.clear_cache()
|
||||||
|
|
||||||
def load_config(app):
|
|
||||||
"""Load and resolve the configuration."""
|
|
||||||
# Lade die Konfigurationsdatei
|
|
||||||
with open("config.yaml", "r") as f:
|
|
||||||
config = yaml.safe_load(f)
|
|
||||||
|
|
||||||
# Resolve links in the configuration
|
def load_config(app):
|
||||||
|
"""Load and resolve the configuration from config.yaml."""
|
||||||
|
with open("config.yaml", "r", encoding="utf-8") as handle:
|
||||||
|
config = yaml.safe_load(handle)
|
||||||
|
|
||||||
|
if config.get("nasa_api_key"):
|
||||||
|
app.config["NASA_API_KEY"] = config["nasa_api_key"]
|
||||||
|
|
||||||
resolver = ConfigurationResolver(config)
|
resolver = ConfigurationResolver(config)
|
||||||
resolver.resolve_links()
|
resolver.resolve_links()
|
||||||
# Update the app configuration
|
|
||||||
app.config.update(resolver.get_config())
|
app.config.update(resolver.get_config())
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
load_config(app)
|
|
||||||
|
|
||||||
# Hole die Umgebungsvariable FLASK_ENV oder setze einen Standardwert
|
def cache_icons_and_logos(app):
|
||||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
"""Cache all icons and logos to local files, with a source fallback."""
|
||||||
|
for card in app.config["cards"]:
|
||||||
|
icon = card.get("icon", {})
|
||||||
|
if icon.get("source"):
|
||||||
|
cached = cache_manager.cache_file(icon["source"])
|
||||||
|
icon["cache"] = cached or icon["source"]
|
||||||
|
|
||||||
|
company_logo = app.config["company"]["logo"]
|
||||||
|
cached = cache_manager.cache_file(company_logo["source"])
|
||||||
|
company_logo["cache"] = cached or company_logo["source"]
|
||||||
|
|
||||||
|
favicon = app.config["platform"]["favicon"]
|
||||||
|
cached = cache_manager.cache_file(favicon["source"])
|
||||||
|
favicon["cache"] = cached or favicon["source"]
|
||||||
|
|
||||||
|
platform_logo = app.config["platform"]["logo"]
|
||||||
|
cached = cache_manager.cache_file(platform_logo["source"])
|
||||||
|
platform_logo["cache"] = cached or platform_logo["source"]
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize Flask app
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Load configuration and cache assets on startup
|
||||||
|
load_config(app)
|
||||||
|
cache_icons_and_logos(app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def utility_processor():
|
||||||
|
def include_svg(path):
|
||||||
|
full_path = os.path.join(current_app.root_path, "static", path)
|
||||||
|
try:
|
||||||
|
with open(full_path, "r", encoding="utf-8") as handle:
|
||||||
|
svg = handle.read()
|
||||||
|
# Trusted local SVG asset shipped with the application package.
|
||||||
|
return Markup(svg) # nosec B704
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return dict(include_svg=include_svg)
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def reload_config_in_dev():
|
def reload_config_in_dev():
|
||||||
|
"""Reload config and recache icons before each request in development mode."""
|
||||||
if FLASK_ENV == "development":
|
if FLASK_ENV == "development":
|
||||||
load_config(app)
|
load_config(app)
|
||||||
|
cache_icons_and_logos(app)
|
||||||
|
|
||||||
# Cache the icons
|
|
||||||
for card in app.config["cards"]:
|
|
||||||
card["icon"]["cache"] = cache_manager.cache_file(card["icon"]["source"])
|
|
||||||
|
|
||||||
app.config["company"]["logo"]["cache"] = cache_manager.cache_file(app.config["company"]["logo"]["source"])
|
@app.route("/")
|
||||||
app.config["company"]["favicon"]["cache"] = cache_manager.cache_file(app.config["company"]["favicon"]["source"])
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
def index():
|
||||||
return render_template("pages/index.html.j2", cards=app.config["cards"], company=app.config["company"], navigation=app.config["navigation"])
|
"""Render the main index page."""
|
||||||
|
cards = app.config["cards"]
|
||||||
|
lg_classes, md_classes = compute_card_classes(cards)
|
||||||
|
apod_bg = None
|
||||||
|
api_key = app.config.get("NASA_API_KEY")
|
||||||
|
if api_key:
|
||||||
|
resp = requests.get(
|
||||||
|
"https://api.nasa.gov/planetary/apod",
|
||||||
|
params={"api_key": api_key},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.ok:
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("media_type") == "image":
|
||||||
|
apod_bg = data.get("url")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"pages/index.html.j2",
|
||||||
|
cards=cards,
|
||||||
|
company=app.config["company"],
|
||||||
|
navigation=app.config["navigation"],
|
||||||
|
platform=app.config["platform"],
|
||||||
|
lg_classes=lg_classes,
|
||||||
|
md_classes=md_classes,
|
||||||
|
apod_bg=apod_bg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=5000)
|
app.run(
|
||||||
|
debug=(FLASK_ENV == "development"),
|
||||||
|
host=FLASK_HOST,
|
||||||
|
port=FLASK_PORT,
|
||||||
|
use_reloader=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
accounts:
|
accounts:
|
||||||
name: Online Accounts
|
nasa_api_key: YOUR_REAL_KEY_HERE
|
||||||
|
name: Online Presence
|
||||||
description: Discover my online presence.
|
description: Discover my online presence.
|
||||||
icon:
|
icon:
|
||||||
class: fa-solid fa-users
|
class: fa-solid fa-users
|
||||||
children:
|
children:
|
||||||
- name: Channels
|
- name: Publishing Channels
|
||||||
description: Platforms where I share content.
|
description: Platforms where I share content.
|
||||||
icon:
|
icon:
|
||||||
class: fas fa-newspaper
|
class: fas fa-newspaper
|
||||||
@@ -29,14 +30,14 @@ accounts:
|
|||||||
identifier: kevinbirkenbach
|
identifier: kevinbirkenbach
|
||||||
warning: I rarely use X/Twitter and recommend alternative platforms like Mastodon.
|
warning: I rarely use X/Twitter and recommend alternative platforms like Mastodon.
|
||||||
alternatives:
|
alternatives:
|
||||||
- link: accounts.channels.microblogs.mastodon
|
- link: accounts.publishingchannels.microblogs.mastodon
|
||||||
- name: Bluesky
|
- name: Bluesky
|
||||||
description: Follow me on Bluesky (coming soon).
|
description: Follow me on Bluesky (coming soon).
|
||||||
icon:
|
icon:
|
||||||
class: fa-brands fa-bluesky
|
class: fa-brands fa-bluesky
|
||||||
info: Bluesky is coming soon.
|
info: Bluesky is coming soon.
|
||||||
alternatives:
|
alternatives:
|
||||||
- link: accounts.channels.microblogs.mastodon
|
- link: accounts.publishingchannels.microblogs.mastodon
|
||||||
|
|
||||||
- name: Pictures
|
- name: Pictures
|
||||||
description: View my photography.
|
description: View my photography.
|
||||||
@@ -56,7 +57,7 @@ accounts:
|
|||||||
identifier: kevinveenbirkenbach
|
identifier: kevinveenbirkenbach
|
||||||
warning: Platforms by Meta (e.g., Instagram, Facebook) may compromise your data privacy. Consider using decentralized alternatives.
|
warning: Platforms by Meta (e.g., Instagram, Facebook) may compromise your data privacy. Consider using decentralized alternatives.
|
||||||
alternatives:
|
alternatives:
|
||||||
- link: accounts.channels.pictures.pixelfed
|
- link: accounts.publishingchannels.pictures.pixelfed
|
||||||
|
|
||||||
- name: Videos
|
- name: Videos
|
||||||
description: Watch my video content.
|
description: Watch my video content.
|
||||||
@@ -75,7 +76,7 @@ accounts:
|
|||||||
url: https://s.veen.world/youtube
|
url: https://s.veen.world/youtube
|
||||||
warning: I no longer publish videos on YouTube. Please visit my Peertube channel instead.
|
warning: I no longer publish videos on YouTube. Please visit my Peertube channel instead.
|
||||||
alternatives:
|
alternatives:
|
||||||
- link: accounts.channels.videos.peertube
|
- link: accounts.publishingchannels.videos.peertube
|
||||||
|
|
||||||
- name: Blog
|
- name: Blog
|
||||||
description: Read my articles and stories.
|
description: Read my articles and stories.
|
||||||
@@ -99,17 +100,25 @@ accounts:
|
|||||||
class: fa-solid fa-code
|
class: fa-solid fa-code
|
||||||
url: https://git.veen.world/kevinveenbirkenbach
|
url: https://git.veen.world/kevinveenbirkenbach
|
||||||
|
|
||||||
- name: Social Media
|
- name: Social Networks
|
||||||
description: Social and developer platforms.
|
description: Social and developer platforms.
|
||||||
icon:
|
icon:
|
||||||
class: fa-brands fa-meta
|
class: fa fa-users
|
||||||
children:
|
children:
|
||||||
- name: Facebook
|
- name: Facebook
|
||||||
|
warning: I recommend to don't use Facebook and connect instead with me via the Fediverse. Check out the listed alternatives.
|
||||||
description: Visit my Facebook page.
|
description: Visit my Facebook page.
|
||||||
icon:
|
icon:
|
||||||
class: fa-brands fa-facebook
|
class: fa-brands fa-facebook
|
||||||
url: https://www.facebook.com/kevinveenbirkenbach
|
url: https://www.facebook.com/kevinveenbirkenbach
|
||||||
|
alternatives:
|
||||||
|
- link: accounts.socialnetworks.friendica
|
||||||
|
- name: Friendica
|
||||||
|
description: Visit my friendica profile
|
||||||
|
icon:
|
||||||
|
class: fas fa-network-wired
|
||||||
|
url: https://s.veen.world/friendica
|
||||||
|
identifier: "kevinveenbirkenbach@friendica.veen.world"
|
||||||
- link: navigation.header.contact.messenger
|
- link: navigation.header.contact.messenger
|
||||||
|
|
||||||
- name: Career Profiles
|
- name: Career Profiles
|
||||||
@@ -121,13 +130,27 @@ accounts:
|
|||||||
description: View my XING profile.
|
description: View my XING profile.
|
||||||
icon:
|
icon:
|
||||||
class: bi bi-building
|
class: bi bi-building
|
||||||
url: https://www.xing.com/profile/Kevin_VeenBirkenbach
|
url: https://s.veen.world/xing
|
||||||
- name: LinkedIn
|
- name: LinkedIn
|
||||||
description: Connect with me on LinkedIn.
|
description: Connect with me on LinkedIn.
|
||||||
icon:
|
icon:
|
||||||
class: bi bi-linkedin
|
class: bi bi-linkedin
|
||||||
url: https://www.linkedin.com/in/kevinveenbirkenbach
|
url: https://s.veen.world/linkedin
|
||||||
|
- name: upwork.com
|
||||||
|
description: Check out my profile on upwork
|
||||||
|
icon:
|
||||||
|
class: fas fa-users
|
||||||
|
url: https://s.veen.world/upwork
|
||||||
|
- name: freelancermap.de
|
||||||
|
description: Check out my profile on freelancermap.de
|
||||||
|
icon:
|
||||||
|
class: fas fa-people-arrows
|
||||||
|
url: https://s.veen.world/freelancermap
|
||||||
|
- name: malt
|
||||||
|
description: Check out my profile on malt
|
||||||
|
icon:
|
||||||
|
class: fas fa-sun
|
||||||
|
url: https://s.veen.world/malt
|
||||||
- name: Sports
|
- name: Sports
|
||||||
description: My sports activities and logs.
|
description: My sports activities and logs.
|
||||||
icon:
|
icon:
|
||||||
@@ -168,6 +191,12 @@ accounts:
|
|||||||
class: fa-brands fa-discourse
|
class: fa-brands fa-discourse
|
||||||
url: https://forum.veen.world/u/kevinveenbirkenbach
|
url: https://forum.veen.world/u/kevinveenbirkenbach
|
||||||
|
|
||||||
|
- name: Nextcloud
|
||||||
|
description: Share data with me via nextcloud
|
||||||
|
icon:
|
||||||
|
class: fa fa-cloud
|
||||||
|
url: https://s.veen.world/cloud
|
||||||
|
|
||||||
cards:
|
cards:
|
||||||
- icon:
|
- icon:
|
||||||
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
||||||
@@ -177,6 +206,7 @@ cards:
|
|||||||
ensuring agile principles are effectively implemented for sustainable success.
|
ensuring agile principles are effectively implemented for sustainable success.
|
||||||
url: https://www.agile-coach.world
|
url: https://www.agile-coach.world
|
||||||
link_text: www.agile-coach.world
|
link_text: www.agile-coach.world
|
||||||
|
iframe: true
|
||||||
- icon:
|
- icon:
|
||||||
source: https://cloud.veen.world/s/logo_personal_coach_512x512/download
|
source: https://cloud.veen.world/s/logo_personal_coach_512x512/download
|
||||||
title: Personal Coach
|
title: Personal Coach
|
||||||
@@ -236,59 +266,66 @@ cards:
|
|||||||
and embrace positive life changes.
|
and embrace positive life changes.
|
||||||
url: https://www.hypno.veen.world
|
url: https://www.hypno.veen.world
|
||||||
link_text: www.hypno.veen.world
|
link_text: www.hypno.veen.world
|
||||||
- icon:
|
#- icon:
|
||||||
source: https://cloud.veen.world/s/logo_skydiver_512x512/download
|
# source: https://cloud.veen.world/s/logo_skydiver_512x512/download
|
||||||
title: Aerospace Consultant
|
# title: Aerospace Consultant
|
||||||
text: As an Aerospace Consultant with aviation credentials, including a Sport Pilot
|
# text: As an Aerospace Consultant with aviation credentials, including a Sport Pilot
|
||||||
License for Parachutes, and a Restricted Radiotelephony and Operator's Certificate
|
# License for Parachutes, and a Restricted Radiotelephony and Operator's Certificate
|
||||||
I deliver expert consulting services. Currently training for my Private Pilot
|
# I deliver expert consulting services. Currently training for my Private Pilot
|
||||||
License, I specialize in guiding clients through aviation regulations, safety
|
# License, I specialize in guiding clients through aviation regulations, safety
|
||||||
standards, and operational efficiency.
|
# standards, and operational efficiency.
|
||||||
url:
|
# url:
|
||||||
link_text: Website under construction
|
# link_text: Website under construction
|
||||||
- icon:
|
#- icon:
|
||||||
source: https://cloud.veen.world/s/logo_hunter_512x512/download
|
# source: https://cloud.veen.world/s/logo_hunter_512x512/download
|
||||||
title: Wildlife Expert
|
# title: Wildlife Expert
|
||||||
text: As a certified hunter and wildlife coach, I offer educational programs, nature
|
# text: As a certified hunter and wildlife coach, I offer educational programs, nature
|
||||||
walks, survival trainings, and photo expeditions, merging ecological knowledge
|
# walks, survival trainings, and photo expeditions, merging ecological knowledge
|
||||||
with nature respect. My goal is to foster sustainable conservation and enhance
|
# with nature respect. My goal is to foster sustainable conservation and enhance
|
||||||
appreciation for the natural world through responsible practices.
|
# appreciation for the natural world through responsible practices.
|
||||||
url:
|
# url:
|
||||||
link_text: Website under construction
|
# link_text: Website under construction
|
||||||
- icon:
|
#- icon:
|
||||||
source: https://cloud.veen.world/s/logo_diver_512x512/download
|
# source: https://cloud.veen.world/s/logo_diver_512x512/download
|
||||||
title: Master Diver
|
# title: Master Diver
|
||||||
text: As a certified master diver with trainings in various specialties, I offer
|
# text: As a certified master diver with trainings in various specialties, I offer
|
||||||
diving instruction, underwater photography, and guided dive tours. My experience
|
# diving instruction, underwater photography, and guided dive tours. My experience
|
||||||
ensures safe and enriching underwater adventures, highlighting marine conservation
|
# ensures safe and enriching underwater adventures, highlighting marine conservation
|
||||||
and the wonders of aquatic ecosystems.
|
# and the wonders of aquatic ecosystems.
|
||||||
url:
|
# url:
|
||||||
link_text: Website under construction
|
# link_text: Website under construction
|
||||||
- icon:
|
#- icon:
|
||||||
source: https://cloud.veen.world/s/logo_massage_therapist_512x512/download
|
# source: https://cloud.veen.world/s/logo_massage_therapist_512x512/download
|
||||||
title: Massage Therapist
|
# title: Massage Therapist
|
||||||
text: Certified in Tantra Massage, I offer unique full-body rituals to awaken senses
|
# text: Certified in Tantra Massage, I offer unique full-body rituals to awaken senses
|
||||||
and harmonize body and mind. My sessions, a blend of ancient Tantra and modern
|
# and harmonize body and mind. My sessions, a blend of ancient Tantra and modern
|
||||||
relaxation, focus on energy flow, personal growth, and spiritual awakening.
|
# relaxation, focus on energy flow, personal growth, and spiritual awakening.
|
||||||
url:
|
# url:
|
||||||
link_text: Website under construction
|
# link_text: Website under construction
|
||||||
company:
|
platform:
|
||||||
titel: Kevin Veen-Birkenbach
|
titel: Kevin Veen-Birkenbach
|
||||||
subtitel: Consulting and Coaching Solutions
|
subtitel: Consulting and Coaching Solutions
|
||||||
logo:
|
logo:
|
||||||
source: https://cloud.veen.world/s/logo_face_512x512/download
|
source: https://cloud.veen.world/s/logo_face_512x512/download
|
||||||
favicon:
|
favicon:
|
||||||
source: https://cloud.veen.world/s/veen_world_favicon/download
|
source: https://cloud.veen.world/s/veen_world_favicon/download
|
||||||
|
company:
|
||||||
|
titel: Kevin Veen-Birkenbach
|
||||||
|
subtitel: Consulting and Coaching Solutions
|
||||||
|
logo:
|
||||||
|
source: https://cloud.veen.world/s/logo_cymais_512x512/download
|
||||||
address:
|
address:
|
||||||
street: Afrikanische Straße 43
|
street: Afrikanische Straße 43
|
||||||
postal_code: DE-13351
|
postal_code: DE-13351
|
||||||
city: Berlin
|
city: Berlin
|
||||||
country: Germany
|
country: Germany
|
||||||
imprint_url: https://s.veen.world/imprint
|
imprint: https://veen.world/
|
||||||
|
|
||||||
navigation:
|
navigation:
|
||||||
header:
|
header:
|
||||||
children:
|
children:
|
||||||
- link: accounts.channels.children
|
- link: accounts.publishingchannels.children
|
||||||
|
- link: accounts.socialnetworks
|
||||||
- name: Contact
|
- name: Contact
|
||||||
description: Get in touch
|
description: Get in touch
|
||||||
icon:
|
icon:
|
||||||
@@ -429,6 +466,12 @@ navigation:
|
|||||||
class: bi bi-clipboard2-check-fill
|
class: bi bi-clipboard2-check-fill
|
||||||
url: https://kanban.veen.world/
|
url: https://kanban.veen.world/
|
||||||
|
|
||||||
|
- name: Snipe IT
|
||||||
|
description: Manage my inventory
|
||||||
|
icon:
|
||||||
|
class: fas fa-box-open
|
||||||
|
url: https://inventory.veen.world/
|
||||||
|
|
||||||
- name: Communication
|
- name: Communication
|
||||||
icon:
|
icon:
|
||||||
class: fa-solid fa-comments
|
class: fa-solid fa-comments
|
||||||
@@ -450,16 +493,34 @@ navigation:
|
|||||||
icon:
|
icon:
|
||||||
class: fa-solid fa-envelope
|
class: fa-solid fa-envelope
|
||||||
url: https://mail.veen.world/
|
url: https://mail.veen.world/
|
||||||
- name: Tools
|
- name: Administration
|
||||||
icon:
|
icon:
|
||||||
class: fas fa-tools
|
class: fas fa-building
|
||||||
children:
|
children:
|
||||||
- name: Matomo
|
- name: Matomo
|
||||||
description: Analyze with Matomo
|
description: Analyze with Matomo
|
||||||
icon:
|
icon:
|
||||||
class: fa-solid fa-chart-simple
|
class: fa-solid fa-chart-simple
|
||||||
url: https://matomo.veen.world/
|
url: https://matomo.veen.world/
|
||||||
|
- name: phpMyAdmin
|
||||||
|
description: Administrate MySQL and MariaDB databases
|
||||||
|
icon:
|
||||||
|
class: fas fa-database
|
||||||
|
url: https://phpmyadmin.veen.world/
|
||||||
|
- name: Keycloak
|
||||||
|
description: Manage User via Keycloak
|
||||||
|
icon:
|
||||||
|
class: fas fa-user-shield
|
||||||
|
url: https://auth.veen.world/admin
|
||||||
|
- name: LDAP
|
||||||
|
description: Manage LDAP
|
||||||
|
icon:
|
||||||
|
class: fas fa-key
|
||||||
|
url: https://ldap.veen.world/
|
||||||
|
- name: Tools
|
||||||
|
icon:
|
||||||
|
class: fas fa-tools
|
||||||
|
children:
|
||||||
- name: Baserow
|
- name: Baserow
|
||||||
description: Organize with Baserow
|
description: Organize with Baserow
|
||||||
icon:
|
icon:
|
||||||
@@ -476,7 +537,6 @@ navigation:
|
|||||||
icon:
|
icon:
|
||||||
class: fa-solid fa-cloud
|
class: fa-solid fa-cloud
|
||||||
url: https://cloud.veen.world/
|
url: https://cloud.veen.world/
|
||||||
|
|
||||||
- name: About Me
|
- name: About Me
|
||||||
description: All information about me
|
description: All information about me
|
||||||
icon:
|
icon:
|
||||||
@@ -557,9 +617,67 @@ navigation:
|
|||||||
class: fa-solid fa-layer-group
|
class: fa-solid fa-layer-group
|
||||||
url: https://s.veen.world/skillmatrix
|
url: https://s.veen.world/skillmatrix
|
||||||
- link: accounts
|
- link: accounts
|
||||||
|
- name: Support Me
|
||||||
|
description: "Discover all the ways you can support my work."
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-hands-helping
|
||||||
|
children:
|
||||||
|
- name: Buy me a Coffee
|
||||||
|
description: "Support my work with a coffee – every cup helps!"
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-mug-hot
|
||||||
|
url: https://s.veen.world/buymeacoffee
|
||||||
|
- name: Patreon
|
||||||
|
description: "Become a member and support me monthly with exclusive content."
|
||||||
|
icon:
|
||||||
|
class: fa-brands fa-patreon
|
||||||
|
url: https://s.veen.world/patreon
|
||||||
|
- name: PayPal
|
||||||
|
description: "Donate to my open source projects with a one-time or monthly PayPal contribution."
|
||||||
|
icon:
|
||||||
|
class: fa-brands fa-paypal
|
||||||
|
url: https://s.veen.world/paypaldonate
|
||||||
|
- name: GitHub Sponsors
|
||||||
|
description: "Directly support my projects through GitHub Sponsors."
|
||||||
|
icon:
|
||||||
|
class: fa-brands fa-github
|
||||||
|
url: https://s.veen.world/githubsponsors
|
||||||
- name: Imprint
|
- name: Imprint
|
||||||
description: Check out the imprint information
|
description: Check out the imprint information
|
||||||
icon:
|
icon:
|
||||||
class: fa-solid fa-scale-balanced
|
class: fa-solid fa-scale-balanced
|
||||||
url: https://s.veen.world/imprint
|
url: https://s.veen.world/imprint
|
||||||
|
iframe: true
|
||||||
|
- name: Settings
|
||||||
|
description: Application settings
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-cog
|
||||||
|
children:
|
||||||
|
- name: Toggle Fullscreen
|
||||||
|
description: Enter or exit fullscreen mode
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-expand-arrows-alt
|
||||||
|
onclick: "toggleFullscreen()"
|
||||||
|
- name: Toggle Full Width
|
||||||
|
description: Switch between normal and full-width layout
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-arrows-left-right
|
||||||
|
onclick: "setFullWidth(!initFullWidthFromUrl())"
|
||||||
|
- name: Open in new tab
|
||||||
|
description: Open the currently embedded iframe URL in a fresh browser tab
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-up-right-from-square
|
||||||
|
onclick: openIframeInNewTab()
|
||||||
|
- name: Print
|
||||||
|
description: Print the current view
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-print
|
||||||
|
onclick: window.print()
|
||||||
|
- name: Zoom +
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-search-plus
|
||||||
|
onclick: zoomPage(1.1)
|
||||||
|
- name: Zoom –
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-search-minus
|
||||||
|
onclick: zoomPage(0.9)
|
||||||
|
|||||||
19
app/cypress.config.js
Normal file
19
app/cypress.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// cypress.config.js
|
||||||
|
const { defineConfig } = require('cypress');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
e2e: {
|
||||||
|
// your app under test must already be running on this port
|
||||||
|
baseUrl: `http://localhost:${process.env.PORT || 5001}`,
|
||||||
|
defaultCommandTimeout: 60000,
|
||||||
|
pageLoadTimeout: 60000,
|
||||||
|
requestTimeout: 1500,
|
||||||
|
responseTimeout: 15000,
|
||||||
|
specPattern: 'cypress/e2e/**/*.spec.js',
|
||||||
|
supportFile: false,
|
||||||
|
setupNodeEvents(on, config) {
|
||||||
|
// here you could hook into events, but we don’t need anything special
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
90
app/cypress/e2e/container.spec.js
Normal file
90
app/cypress/e2e/container.spec.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// cypress/e2e/container.spec.js
|
||||||
|
|
||||||
|
describe('Custom Scroll & Container Resizing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Assumes your app is running at baseUrl, and container.js is loaded on “/”
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on load, the scroll-container gets a positive height and proper overflow', () => {
|
||||||
|
// wait for our JS to run
|
||||||
|
cy.window().should('have.property', 'adjustScrollContainerHeight');
|
||||||
|
|
||||||
|
// Grab the inline style of .scroll-container
|
||||||
|
cy.get('.scroll-container')
|
||||||
|
.should('have.attr', 'style')
|
||||||
|
.then(style => {
|
||||||
|
// height:<number>px must be present
|
||||||
|
const m = style.match(/height:\s*(\d+(?:\.\d+)?)px/);
|
||||||
|
expect(m, 'height set').to.not.be.null;
|
||||||
|
expect(parseFloat(m[1]), 'height > 0').to.be.greaterThan(0);
|
||||||
|
|
||||||
|
// overflow shorthand should include both hidden & auto (order-insensitive)
|
||||||
|
expect(style).to.include('overflow:');
|
||||||
|
expect(style).to.match(/overflow:\s*(hidden\s+auto|auto\s+hidden)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on window resize, scroll-container height updates', () => {
|
||||||
|
// record original height
|
||||||
|
cy.get('.scroll-container')
|
||||||
|
.invoke('css', 'height')
|
||||||
|
.then(orig => {
|
||||||
|
// resize to a smaller viewport
|
||||||
|
cy.viewport(320, 480);
|
||||||
|
cy.wait(100); // allow resize handler to fire
|
||||||
|
|
||||||
|
cy.get('.scroll-container')
|
||||||
|
.invoke('css', 'height')
|
||||||
|
.then(newH => {
|
||||||
|
expect(parseFloat(newH), 'height changed on resize').to.not.equal(parseFloat(orig));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
context('custom scrollbar thumb', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// inject tall content to force scrolling
|
||||||
|
cy.get('.scroll-container').then($sc => {
|
||||||
|
$sc[0].innerHTML = '<div style="height:2000px">long</div>';
|
||||||
|
});
|
||||||
|
// re-run scrollbar setup
|
||||||
|
cy.window().invoke('updateCustomScrollbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a thumb with reasonable size & position', () => {
|
||||||
|
cy.get('#custom-scrollbar').should('have.css', 'opacity', '1');
|
||||||
|
|
||||||
|
cy.get('#scroll-thumb')
|
||||||
|
.should('have.css', 'height')
|
||||||
|
.then(h => {
|
||||||
|
const hh = parseFloat(h);
|
||||||
|
expect(hh).to.be.at.least(20);
|
||||||
|
// ensure thumb is smaller than container
|
||||||
|
cy.get('#custom-scrollbar')
|
||||||
|
.invoke('css', 'height')
|
||||||
|
.then(ch => {
|
||||||
|
expect(hh).to.be.lessThan(parseFloat(ch));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// scroll a bit and verify thumb.top changes
|
||||||
|
cy.get('.scroll-container').scrollTo(0, 200);
|
||||||
|
cy.wait(50);
|
||||||
|
cy.get('#scroll-thumb')
|
||||||
|
.invoke('css', 'top')
|
||||||
|
.then(t => {
|
||||||
|
expect(parseFloat(t)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides scrollbar when content fits', () => {
|
||||||
|
// remove overflow
|
||||||
|
cy.get('.scroll-container').then($sc => {
|
||||||
|
$sc[0].innerHTML = '<div style="height:10px">tiny</div>';
|
||||||
|
});
|
||||||
|
cy.window().invoke('updateCustomScrollbar');
|
||||||
|
cy.get('#custom-scrollbar').should('have.css', 'opacity', '0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
85
app/cypress/e2e/fullscreen.spec.js
Normal file
85
app/cypress/e2e/fullscreen.spec.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// cypress/e2e/fullscreen.spec.js
|
||||||
|
|
||||||
|
describe('Fullscreen Toggle', () => {
|
||||||
|
const ROOT = '/';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit(ROOT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to normal mode when no fullscreen param is present', () => {
|
||||||
|
// Body should not have fullscreen class
|
||||||
|
cy.get('body').should('not.have.class', 'fullscreen');
|
||||||
|
|
||||||
|
// URL should not include `fullscreen`
|
||||||
|
cy.url().should('not.include', 'fullscreen=');
|
||||||
|
|
||||||
|
// Header and footer should be visible (max-height > 0)
|
||||||
|
cy.get('header').should('have.css', 'max-height').and(value => {
|
||||||
|
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
cy.get('footer').should('have.css', 'max-height').and(value => {
|
||||||
|
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initFullscreenFromUrl() picks up ?fullscreen=1 on load', () => {
|
||||||
|
cy.visit(`${ROOT}?fullscreen=1`);
|
||||||
|
|
||||||
|
cy.get('body').should('have.class', 'fullscreen');
|
||||||
|
cy.url().should('include', 'fullscreen=1');
|
||||||
|
|
||||||
|
// Header and footer should be collapsed (max-height == 0)
|
||||||
|
cy.get('header').should('have.css', 'max-height', '0px');
|
||||||
|
cy.get('footer').should('have.css', 'max-height', '0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enterFullscreen() adds fullscreen class, sets full width, and updates URL', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.exitFullscreen(); // ensure starting state
|
||||||
|
win.enterFullscreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('body').should('have.class', 'fullscreen');
|
||||||
|
cy.url().should('include', 'fullscreen=1');
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container-fluid');
|
||||||
|
|
||||||
|
cy.get('header').should('have.css', 'max-height', '0px');
|
||||||
|
cy.get('footer').should('have.css', 'max-height', '0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exitFullscreen() removes fullscreen class, resets width, and URL param', () => {
|
||||||
|
// start in fullscreen
|
||||||
|
cy.window().invoke('enterFullscreen');
|
||||||
|
|
||||||
|
// then exit
|
||||||
|
cy.window().invoke('exitFullscreen');
|
||||||
|
|
||||||
|
cy.get('body').should('not.have.class', 'fullscreen');
|
||||||
|
cy.url().should('not.include', 'fullscreen=');
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container')
|
||||||
|
.and('not.have.class', 'container-fluid');
|
||||||
|
|
||||||
|
// Header and footer should be expanded again
|
||||||
|
cy.get('header').should('have.css', 'max-height').and(value => {
|
||||||
|
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
cy.get('footer').should('have.css', 'max-height').and(value => {
|
||||||
|
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleFullscreen() toggles into and out of fullscreen', () => {
|
||||||
|
// Toggle into fullscreen
|
||||||
|
cy.window().invoke('toggleFullscreen');
|
||||||
|
cy.get('body').should('have.class', 'fullscreen');
|
||||||
|
cy.url().should('include', 'fullscreen=1');
|
||||||
|
|
||||||
|
// Toggle back
|
||||||
|
cy.window().invoke('toggleFullscreen');
|
||||||
|
cy.get('body').should('not.have.class', 'fullscreen');
|
||||||
|
cy.url().should('not.include', 'fullscreen=');
|
||||||
|
});
|
||||||
|
});
|
||||||
61
app/cypress/e2e/fullwidth.spec.js
Normal file
61
app/cypress/e2e/fullwidth.spec.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// cypress/e2e/fullwidth.spec.js
|
||||||
|
|
||||||
|
describe('Full-width Toggle', () => {
|
||||||
|
// test page must include your <div class="container"> wrapper
|
||||||
|
const ROOT = '/';
|
||||||
|
|
||||||
|
it('defaults to .container when no param is present', () => {
|
||||||
|
cy.visit(ROOT);
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container')
|
||||||
|
.and('not.have.class', 'container-fluid');
|
||||||
|
|
||||||
|
// URL should not include `fullwidth`
|
||||||
|
cy.url().should('not.include', 'fullwidth=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initFullWidthFromUrl() picks up ?fullwidth=1 on load', () => {
|
||||||
|
cy.visit(`${ROOT}?fullwidth=1`);
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container-fluid')
|
||||||
|
.and('not.have.class', 'container');
|
||||||
|
cy.url().should('include', 'fullwidth=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setFullWidth(true) switches to container-fluid and updates URL', () => {
|
||||||
|
cy.visit(ROOT);
|
||||||
|
|
||||||
|
// call your global function
|
||||||
|
cy.window().invoke('setFullWidth', true);
|
||||||
|
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container-fluid')
|
||||||
|
.and('not.have.class', 'container');
|
||||||
|
|
||||||
|
cy.url().should('include', 'fullwidth=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setFullWidth(false) reverts to container and removes URL param', () => {
|
||||||
|
cy.visit(`${ROOT}?fullwidth=1`);
|
||||||
|
|
||||||
|
// now reset
|
||||||
|
cy.window().invoke('setFullWidth', false);
|
||||||
|
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container')
|
||||||
|
.and('not.have.class', 'container-fluid');
|
||||||
|
|
||||||
|
cy.url().should('not.include', 'fullwidth=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateUrlFullWidth() toggles the query param without changing layout', () => {
|
||||||
|
cy.visit(ROOT);
|
||||||
|
|
||||||
|
// manually toggle URL only
|
||||||
|
cy.window().invoke('updateUrlFullWidth', true);
|
||||||
|
cy.url().should('include', 'fullwidth=1');
|
||||||
|
|
||||||
|
cy.window().invoke('updateUrlFullWidth', false);
|
||||||
|
cy.url().should('not.include', 'fullwidth=');
|
||||||
|
});
|
||||||
|
});
|
||||||
46
app/cypress/e2e/iframe.spec.js
Normal file
46
app/cypress/e2e/iframe.spec.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// cypress/e2e/iframe.spec.js
|
||||||
|
|
||||||
|
describe('Iframe integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Visit the app’s base URL (configured in cypress.config.js)
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the iframe when an .iframe-link is clicked', () => {
|
||||||
|
// Find the first iframe-link on the page
|
||||||
|
cy.get('.iframe-link').first().then($link => {
|
||||||
|
const href = $link.prop('href');
|
||||||
|
|
||||||
|
// Click it
|
||||||
|
cy.wrap($link).click();
|
||||||
|
|
||||||
|
// The URL should now include ?iframe=<encoded href>
|
||||||
|
cy.url().should('include', 'iframe=' + encodeURIComponent(href));
|
||||||
|
|
||||||
|
// The <body> should have the "fullscreen" class
|
||||||
|
cy.get('body').should('have.class', 'fullscreen');
|
||||||
|
|
||||||
|
// And the <main> should contain a visible <iframe src="<href>">
|
||||||
|
cy.get('main iframe')
|
||||||
|
.should('have.attr', 'src', href)
|
||||||
|
.and('be.visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the original content when a .js-restore element is clicked', () => {
|
||||||
|
// First open the iframe
|
||||||
|
cy.get('.iframe-link').first().click();
|
||||||
|
|
||||||
|
// Then click the first .js-restore element (e.g. header or logo)
|
||||||
|
cy.get('.js-restore').first().click();
|
||||||
|
|
||||||
|
// The URL must no longer include the iframe parameter
|
||||||
|
cy.url().should('not.include', 'iframe=');
|
||||||
|
|
||||||
|
// The <body> should no longer have the "fullscreen" class
|
||||||
|
cy.get('body').should('not.have.class', 'fullscreen');
|
||||||
|
|
||||||
|
// And no <iframe> should remain inside <main>
|
||||||
|
cy.get('main iframe').should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
196
app/cypress/e2e/menu.spec.js
Normal file
196
app/cypress/e2e/menu.spec.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// cypress/e2e/menu.spec.js
|
||||||
|
|
||||||
|
describe('Navigation dropdowns', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.viewport(1280, 720);
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens top-level dropdowns with explicit Bootstrap instances', () => {
|
||||||
|
cy.get('#navbarNavheader .nav-item.dropdown > .nav-link.dropdown-toggle')
|
||||||
|
.first()
|
||||||
|
.as('toggle')
|
||||||
|
.should('have.attr', 'data-bs-toggle', 'dropdown')
|
||||||
|
.and('have.attr', 'aria-expanded', 'false');
|
||||||
|
|
||||||
|
cy.get('@toggle').then($toggle => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
expect(win.bootstrap.Dropdown.getInstance($toggle[0])).to.exist;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('@toggle')
|
||||||
|
.parent('.nav-item')
|
||||||
|
.find('> .dropdown-menu')
|
||||||
|
.as('menu')
|
||||||
|
.should('not.have.class', 'show')
|
||||||
|
.should('not.be.visible');
|
||||||
|
|
||||||
|
cy.get('@toggle').click();
|
||||||
|
cy.get('@toggle').should('have.attr', 'aria-expanded', 'true');
|
||||||
|
cy.get('@toggle').parent('.nav-item').should('have.class', 'dropdown');
|
||||||
|
cy.get('@menu')
|
||||||
|
.should('have.class', 'show')
|
||||||
|
.and('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips footer dropdowns upward where there is more space above', () => {
|
||||||
|
cy.get('#navbarNavfooter .nav-item .nav-link.dropdown-toggle')
|
||||||
|
.first()
|
||||||
|
.as('toggle');
|
||||||
|
|
||||||
|
cy.get('@toggle')
|
||||||
|
.parent('.nav-item')
|
||||||
|
.as('item')
|
||||||
|
.find('> .dropdown-menu')
|
||||||
|
.as('menu')
|
||||||
|
.should('not.have.class', 'show');
|
||||||
|
|
||||||
|
// Make sure the footer sits at the bottom of the viewport before clicking
|
||||||
|
// — otherwise the toggle could land near the top and chooseDirection would
|
||||||
|
// keep .dropdown (more space below than above).
|
||||||
|
// ensureScrollable:false because on short pages the body isn't scrollable
|
||||||
|
// and the footer is already in view (which is fine for this test).
|
||||||
|
cy.scrollTo('bottom', { ensureScrollable: false });
|
||||||
|
cy.get('@toggle').then($toggle => {
|
||||||
|
const rect = $toggle[0].getBoundingClientRect();
|
||||||
|
expect(rect.top, 'toggle is in the lower half of the viewport')
|
||||||
|
.to.be.greaterThan(Cypress.config('viewportHeight') / 2);
|
||||||
|
});
|
||||||
|
cy.get('@toggle').click({ scrollBehavior: false });
|
||||||
|
cy.get('@item').should('have.class', 'dropup');
|
||||||
|
cy.get('@item').should('not.have.class', 'dropdown');
|
||||||
|
cy.get('@menu')
|
||||||
|
.should('have.class', 'show')
|
||||||
|
.and('be.visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dynamic Popup', () => {
|
||||||
|
const base = {
|
||||||
|
name: 'Test Item',
|
||||||
|
identifier: 'ABC123',
|
||||||
|
description: 'A simple description',
|
||||||
|
warning: '**Be careful!**',
|
||||||
|
info: '_Some info_',
|
||||||
|
url: null,
|
||||||
|
iframe: false,
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(item = {}) {
|
||||||
|
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders title with icon and text', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i.fa.fa-test')
|
||||||
|
.should('exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain text when no icon', () => {
|
||||||
|
open({ icon: null });
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i')
|
||||||
|
.should('not.exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('have.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows identifier when provided and populates input', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides identifier box when none', () => {
|
||||||
|
open({ identifier: null });
|
||||||
|
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning and info via marked', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalWarning')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalWarningText')
|
||||||
|
.should('contain.html', '<strong>Be careful!</strong>');
|
||||||
|
cy.get('#dynamicModalInfo')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalInfoText')
|
||||||
|
.should('contain.html', '<em>Some info</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides warning/info when none provided', () => {
|
||||||
|
open({ warning: null, info: null });
|
||||||
|
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows description when no URL', () => {
|
||||||
|
open({ url: null, description: 'Only desc' });
|
||||||
|
cy.get('#dynamicDescriptionText')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.and('have.text', 'Only desc');
|
||||||
|
cy.get('#dynamicModalLink').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows link when URL is provided', () => {
|
||||||
|
open({ url: 'https://example.com', description: 'Click me' });
|
||||||
|
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalLinkHref')
|
||||||
|
.should('have.attr', 'href', 'https://example.com')
|
||||||
|
.and('have.text', 'Click me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates alternatives and children lists', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicAlternativesList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Alt One');
|
||||||
|
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Child One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sections when no items', () => {
|
||||||
|
open({ alternatives: [], children: [] });
|
||||||
|
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking an “Open” in list re-opens popup with that item', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesList button').click();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Alt One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy button selects & copies identifier', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicCopyButton').click();
|
||||||
|
cy.window().its('navigator.clipboard.writeText')
|
||||||
|
.should('have.been.calledWith', 'ABC123');
|
||||||
|
cy.window().its('alert')
|
||||||
|
.should('have.been.calledWith', 'Identifier copied to clipboard!');
|
||||||
|
});
|
||||||
|
});
|
||||||
130
app/cypress/e2e/modal.spec.js
Normal file
130
app/cypress/e2e/modal.spec.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// cypress/e2e/dynamic_popup.spec.js
|
||||||
|
|
||||||
|
describe('Dynamic Popup', () => {
|
||||||
|
const base = {
|
||||||
|
name: 'Test Item',
|
||||||
|
identifier: 'ABC123',
|
||||||
|
description: 'A simple description',
|
||||||
|
warning: '**Be careful!**',
|
||||||
|
info: '_Some info_',
|
||||||
|
url: null,
|
||||||
|
iframe: false,
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(item = {}) {
|
||||||
|
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders title with icon and text', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i.fa.fa-test')
|
||||||
|
.should('exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain text when no icon', () => {
|
||||||
|
open({ icon: null });
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i')
|
||||||
|
.should('not.exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('have.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows identifier when provided and populates input', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides identifier box when none', () => {
|
||||||
|
open({ identifier: null });
|
||||||
|
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning and info via marked', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalWarning')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalWarningText')
|
||||||
|
.should('contain.html', '<strong>Be careful!</strong>');
|
||||||
|
cy.get('#dynamicModalInfo')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalInfoText')
|
||||||
|
.should('contain.html', '<em>Some info</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides warning/info when none provided', () => {
|
||||||
|
open({ warning: null, info: null });
|
||||||
|
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows description when no URL', () => {
|
||||||
|
open({ url: null, description: 'Only desc' });
|
||||||
|
cy.get('#dynamicDescriptionText')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.and('have.text', 'Only desc');
|
||||||
|
cy.get('#dynamicModalLink').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows link when URL is provided', () => {
|
||||||
|
open({ url: 'https://example.com', description: 'Click me' });
|
||||||
|
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalLinkHref')
|
||||||
|
.should('have.attr', 'href', 'https://example.com')
|
||||||
|
.and('have.text', 'Click me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates alternatives and children lists', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicAlternativesList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Alt One');
|
||||||
|
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Child One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sections when no items', () => {
|
||||||
|
open({ alternatives: [], children: [] });
|
||||||
|
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking an “Open” in list re-opens popup with that item', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesList button').click();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Alt One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy button selects & copies identifier', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicCopyButton').click();
|
||||||
|
cy.window().its('navigator.clipboard.writeText')
|
||||||
|
.should('have.been.calledWith', 'ABC123');
|
||||||
|
cy.window().its('alert')
|
||||||
|
.should('have.been.calledWith', 'Identifier copied to clipboard!');
|
||||||
|
});
|
||||||
|
});
|
||||||
32
app/cypress/e2e/navbar_logo_visibility.spec.js
Normal file
32
app/cypress/e2e/navbar_logo_visibility.spec.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
describe('Navbar Logo Visibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have #navbar_logo present in the DOM', () => {
|
||||||
|
cy.get('#navbar_logo').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be invisible (opacity 0) by default', () => {
|
||||||
|
cy.get('#navbar_logo')
|
||||||
|
.should('exist')
|
||||||
|
.and('have.css', 'opacity', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should become visible (opacity 1) after entering fullscreen', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.enterFullscreen();
|
||||||
|
});
|
||||||
|
cy.get('#navbar_logo', { timeout: 4000 })
|
||||||
|
.should('have.css', 'opacity', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should become invisible again (opacity 0) after exiting fullscreen', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.enterFullscreen();
|
||||||
|
win.exitFullscreen();
|
||||||
|
});
|
||||||
|
cy.get('#navbar_logo', { timeout: 4000 })
|
||||||
|
.should('have.css', 'opacity', '0');
|
||||||
|
});
|
||||||
|
});
|
||||||
130
app/cypress/e2e/tooltips.spec.js
Normal file
130
app/cypress/e2e/tooltips.spec.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// cypress/e2e/dynamic_popup.spec.js
|
||||||
|
|
||||||
|
describe('Dynamic Popup', () => {
|
||||||
|
const base = {
|
||||||
|
name: 'Test Item',
|
||||||
|
identifier: 'ABC123',
|
||||||
|
description: 'A simple description',
|
||||||
|
warning: '**Be careful!**',
|
||||||
|
info: '_Some info_',
|
||||||
|
url: null,
|
||||||
|
iframe: false,
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(item = {}) {
|
||||||
|
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders title with icon and text', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i.fa.fa-test')
|
||||||
|
.should('exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain text when no icon', () => {
|
||||||
|
open({ icon: null });
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i')
|
||||||
|
.should('not.exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('have.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows identifier when provided and populates input', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides identifier box when none', () => {
|
||||||
|
open({ identifier: null });
|
||||||
|
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning and info via marked', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalWarning')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalWarningText')
|
||||||
|
.should('contain.html', '<strong>Be careful!</strong>');
|
||||||
|
cy.get('#dynamicModalInfo')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalInfoText')
|
||||||
|
.should('contain.html', '<em>Some info</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides warning/info when none provided', () => {
|
||||||
|
open({ warning: null, info: null });
|
||||||
|
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows description when no URL', () => {
|
||||||
|
open({ url: null, description: 'Only desc' });
|
||||||
|
cy.get('#dynamicDescriptionText')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.and('have.text', 'Only desc');
|
||||||
|
cy.get('#dynamicModalLink').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows link when URL is provided', () => {
|
||||||
|
open({ url: 'https://example.com', description: 'Click me' });
|
||||||
|
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalLinkHref')
|
||||||
|
.should('have.attr', 'href', 'https://example.com')
|
||||||
|
.and('have.text', 'Click me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates alternatives and children lists', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicAlternativesList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Alt One');
|
||||||
|
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Child One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sections when no items', () => {
|
||||||
|
open({ alternatives: [], children: [] });
|
||||||
|
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking an “Open” in list re-opens popup with that item', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesList button').click();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Alt One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy button selects & copies identifier', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicCopyButton').click();
|
||||||
|
cy.window().its('navigator.clipboard.writeText')
|
||||||
|
.should('have.been.calledWith', 'ABC123');
|
||||||
|
cy.window().its('alert')
|
||||||
|
.should('have.been.calledWith', 'Identifier copied to clipboard!');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
app/package.json
Normal file
16
app/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
|
"bootstrap": "5.2.2",
|
||||||
|
"bootstrap-icons": "1.9.1",
|
||||||
|
"jquery": "3.6.0",
|
||||||
|
"marked": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"cypress": "^14.5.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node scripts/copy-vendor.js",
|
||||||
|
"postinstall": "node scripts/copy-vendor.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
flask
|
|
||||||
requests
|
|
||||||
pyyaml
|
|
||||||
71
app/scripts/copy-vendor.js
Normal file
71
app/scripts/copy-vendor.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Copies third-party browser assets from node_modules into static/vendor/
|
||||||
|
* so Flask can serve them without any CDN dependency.
|
||||||
|
* Runs automatically via the "postinstall" npm hook.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const NM = path.join(__dirname, '..', 'node_modules');
|
||||||
|
const VENDOR = path.join(__dirname, '..', 'static', 'vendor');
|
||||||
|
|
||||||
|
function copyFile(src, dest) {
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDir(src, dest) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const s = path.join(src, entry.name);
|
||||||
|
const d = path.join(dest, entry.name);
|
||||||
|
entry.isDirectory() ? copyDir(s, d) : fs.copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap CSS + JS bundle
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
|
||||||
|
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
|
||||||
|
);
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
|
||||||
|
path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bootstrap Icons CSS + embedded fonts
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'bootstrap-icons', 'font', 'bootstrap-icons.css'),
|
||||||
|
path.join(VENDOR, 'bootstrap-icons', 'font', 'bootstrap-icons.css')
|
||||||
|
);
|
||||||
|
copyDir(
|
||||||
|
path.join(NM, 'bootstrap-icons', 'font', 'fonts'),
|
||||||
|
path.join(VENDOR, 'bootstrap-icons', 'font', 'fonts')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Font Awesome Free CSS + webfonts
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, '@fortawesome', 'fontawesome-free', 'css', 'all.min.css'),
|
||||||
|
path.join(VENDOR, 'fontawesome', 'css', 'all.min.css')
|
||||||
|
);
|
||||||
|
copyDir(
|
||||||
|
path.join(NM, '@fortawesome', 'fontawesome-free', 'webfonts'),
|
||||||
|
path.join(VENDOR, 'fontawesome', 'webfonts')
|
||||||
|
);
|
||||||
|
|
||||||
|
// marked – browser UMD build (path varies by version)
|
||||||
|
const markedCandidates = [
|
||||||
|
path.join(NM, 'marked', 'marked.min.js'), // v4.x
|
||||||
|
path.join(NM, 'marked', 'lib', 'marked.umd.min.js'), // v5.x
|
||||||
|
path.join(NM, 'marked', 'dist', 'marked.min.js'), // v9+
|
||||||
|
];
|
||||||
|
const markedSrc = markedCandidates.find(p => fs.existsSync(p));
|
||||||
|
if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');
|
||||||
|
copyFile(markedSrc, path.join(VENDOR, 'marked', 'marked.min.js'));
|
||||||
|
|
||||||
|
// jQuery
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'jquery', 'dist', 'jquery.min.js'),
|
||||||
|
path.join(VENDOR, 'jquery', 'jquery.min.js')
|
||||||
|
);
|
||||||
31
app/static/css/custom_scrollbar.css
Normal file
31
app/static/css/custom_scrollbar.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/* Set the scroll container to only scroll vertically */
|
||||||
|
.scroll-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
/* Hide native scrollbar */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar {
|
||||||
|
display: none; /* WebKit */
|
||||||
|
}
|
||||||
|
|
||||||
|
#custom-scrollbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 8px;
|
||||||
|
/* height: 100vh; <-- remove or adjust this line */
|
||||||
|
background: transparent;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The scrollbar thumb */
|
||||||
|
#scroll-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle shadow effect */
|
/* Subtle shadow effect */
|
||||||
.navbar, .card, .dropdown-menu{
|
.navbar, .card, .dropdown-menu {
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,18 @@ a {
|
|||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
transition: background-color 1s ease, transform 1s ease;
|
||||||
|
transition: color 1s ease, transform 1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
/* invert everything inside the card */
|
||||||
|
filter: invert(0.8) hue-rotate(144deg);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -70,7 +82,6 @@ h3.card-title {
|
|||||||
|
|
||||||
/* Footer styles */
|
/* Footer styles */
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 12px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
}
|
}
|
||||||
@@ -84,3 +95,118 @@ h3.card-title {
|
|||||||
h3.footer-title {
|
h3.footer-title {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-img-top i, .card-img-top svg{
|
||||||
|
font-size: 100px;
|
||||||
|
fill: currentColor;
|
||||||
|
width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#navbarNavheader li.nav-item {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#navbarNavfooter li.nav-item {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent nav items from wrapping to a second line.
|
||||||
|
overflow is intentionally NOT set here — overflow-x:auto would
|
||||||
|
implicitly clip overflow-y too and hide dropdown menus that open
|
||||||
|
below the navbar. */
|
||||||
|
div#navbarNavheader .navbar-nav,
|
||||||
|
div#navbarNavfooter .navbar-nav {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
main, footer, header, nav {
|
||||||
|
position: relative;
|
||||||
|
box-shadow:
|
||||||
|
/* Inner shadow */
|
||||||
|
inset 10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Left inner shadow */
|
||||||
|
inset -10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Right inner shadow */
|
||||||
|
/* Outer shadow */
|
||||||
|
10px 0 10px -10px rgba(0, 0, 0, 0.3), /* Right outer shadow */
|
||||||
|
-10px 0 10px -10px rgba(0, 0, 0, 0.3); /* Left outer shadow */
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
header{
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header,
|
||||||
|
footer {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
top: 0;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 1030;
|
||||||
|
background-color: var(--bs-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* at the end of default.css */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe{
|
||||||
|
margin-bottom: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-fluid {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--anim-duration: 3s; /* Basis-Dauer */
|
||||||
|
}
|
||||||
|
|
||||||
|
.container,
|
||||||
|
.container-fluid {
|
||||||
|
transition:
|
||||||
|
max-width var(--anim-duration) ease-in-out,
|
||||||
|
padding-left var(--anim-duration) ease-in-out,
|
||||||
|
padding-right var(--anim-duration) ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navbar_logo {
|
||||||
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: opacity var(--anim-duration) ease-in-out,
|
||||||
|
max-width var(--anim-duration) ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navbar_logo.visible {
|
||||||
|
opacity: 1 !important;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 1. Make sure headers and footers can collapse */
|
||||||
|
header,
|
||||||
|
footer {
|
||||||
|
/* choose a max-height that’s >= your tallest header/footer */
|
||||||
|
max-height: 200px;
|
||||||
|
padding: 1rem;
|
||||||
|
transition:
|
||||||
|
max-height var(--anim-duration) ease-in-out,
|
||||||
|
padding var(--anim-duration) ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. In fullscreen mode, collapse them. overflow: hidden is scoped here
|
||||||
|
so dropdown menus can escape the header in normal mode. */
|
||||||
|
body.fullscreen header,
|
||||||
|
body.fullscreen footer {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
@@ -1,20 +1,34 @@
|
|||||||
/* Top-Level Dropdown-Menü */
|
/* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */
|
||||||
.nav-item .dropdown-menu {
|
.nav-item.dropdown > .dropdown-menu,
|
||||||
position: absolute; /* Wichtig für Positionierung */
|
.nav-item.dropup > .dropdown-menu {
|
||||||
top: 100%; /* Standardmäßige Öffnung nach unten */
|
|
||||||
left: 0;
|
|
||||||
z-index: 1050; /* Damit das Menü über anderen Elementen liegt */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Submenu-Position */
|
|
||||||
.dropdown-submenu > .dropdown-menu {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
left: 0;
|
||||||
left: 100%; /* Öffnen nach rechts */
|
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sicherstellen, dass der Übergang smooth ist */
|
.nav-item.dropdown > .dropdown-menu {
|
||||||
|
top: 100%;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.dropup > .dropdown-menu {
|
||||||
|
top: auto;
|
||||||
|
bottom: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenu position */
|
||||||
|
.dropdown-submenu > .dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 100%; /* Opens to the right */
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure a smooth transition */
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
transition: all 0.3s ease-in-out;
|
transition: all 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nav.navbar {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|||||||
108
app/static/js/container.js
Normal file
108
app/static/js/container.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
function adjustScrollContainerHeight() {
|
||||||
|
const mainEl = document.getElementById('main');
|
||||||
|
const scrollContainer = mainEl.querySelector('.scroll-container');
|
||||||
|
const scrollbarContainer = document.getElementById('custom-scrollbar');
|
||||||
|
const container = mainEl.parentElement;
|
||||||
|
|
||||||
|
let siblingsHeight = 0;
|
||||||
|
Array.from(container.children).forEach(child => {
|
||||||
|
if(child !== mainEl && child !== scrollbarContainer) {
|
||||||
|
const style = window.getComputedStyle(child);
|
||||||
|
const height = child.offsetHeight;
|
||||||
|
const marginTop = parseFloat(style.marginTop) || 0;
|
||||||
|
const marginBottom = parseFloat(style.marginBottom) || 0;
|
||||||
|
siblingsHeight += height + marginTop + marginBottom;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate the available height for the scroll area
|
||||||
|
const availableHeight = window.innerHeight - siblingsHeight;
|
||||||
|
scrollContainer.style.height = availableHeight + 'px';
|
||||||
|
scrollContainer.style.overflowY = 'auto';
|
||||||
|
scrollContainer.style.overflowX = 'hidden';
|
||||||
|
|
||||||
|
// Get the current position and height of the scroll container
|
||||||
|
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Set the position (top) and height of the custom scrollbar track
|
||||||
|
scrollbarContainer.style.top = scrollContainerRect.top + 'px';
|
||||||
|
scrollbarContainer.style.height = scrollContainerRect.height + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', adjustScrollContainerHeight);
|
||||||
|
window.addEventListener('resize', adjustScrollContainerHeight);
|
||||||
|
|
||||||
|
// 2. Updates the thumb (size and position) of the custom scrollbar
|
||||||
|
function updateCustomScrollbar() {
|
||||||
|
const scrollContainer = document.querySelector('.scroll-container');
|
||||||
|
const thumb = document.getElementById('scroll-thumb');
|
||||||
|
const customScrollbar = document.getElementById('custom-scrollbar');
|
||||||
|
if (!scrollContainer || !thumb || !customScrollbar) return;
|
||||||
|
|
||||||
|
const contentHeight = scrollContainer.scrollHeight;
|
||||||
|
const containerHeight = scrollContainer.clientHeight;
|
||||||
|
const scrollTop = scrollContainer.scrollTop;
|
||||||
|
|
||||||
|
// Calculate the thumb height (minimum 20px)
|
||||||
|
let thumbHeight = (containerHeight / contentHeight) * containerHeight;
|
||||||
|
thumbHeight = Math.max(thumbHeight, 20);
|
||||||
|
thumb.style.height = thumbHeight + 'px';
|
||||||
|
|
||||||
|
// Calculate the thumb position
|
||||||
|
const maxScrollTop = contentHeight - containerHeight;
|
||||||
|
const maxThumbTop = containerHeight - thumbHeight;
|
||||||
|
const thumbTop = maxScrollTop ? (scrollTop / maxScrollTop) * maxThumbTop : 0;
|
||||||
|
thumb.style.top = thumbTop + 'px';
|
||||||
|
|
||||||
|
// Show the scrollbar if content overflows, otherwise hide it
|
||||||
|
customScrollbar.style.opacity = contentHeight > containerHeight ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the thumb when the container is scrolled
|
||||||
|
const scrollContainer = document.querySelector('.scroll-container');
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.addEventListener('scroll', updateCustomScrollbar);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', updateCustomScrollbar);
|
||||||
|
window.addEventListener('load', updateCustomScrollbar);
|
||||||
|
|
||||||
|
// 3. Interactivity: Enable drag & drop for the scroll thumb
|
||||||
|
let isDragging = false;
|
||||||
|
let dragStartY = 0;
|
||||||
|
let scrollStartY = 0;
|
||||||
|
|
||||||
|
const thumb = document.getElementById('scroll-thumb');
|
||||||
|
|
||||||
|
if (thumb) {
|
||||||
|
thumb.addEventListener('mousedown', function(e) {
|
||||||
|
isDragging = true;
|
||||||
|
dragStartY = e.clientY;
|
||||||
|
scrollStartY = scrollContainer.scrollTop;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', function(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const containerHeight = scrollContainer.clientHeight;
|
||||||
|
const contentHeight = scrollContainer.scrollHeight;
|
||||||
|
const thumbHeight = thumb.offsetHeight;
|
||||||
|
|
||||||
|
const maxScrollTop = contentHeight - containerHeight;
|
||||||
|
const maxThumbTop = containerHeight - thumbHeight;
|
||||||
|
|
||||||
|
const deltaY = e.clientY - dragStartY;
|
||||||
|
// Calculate the new thumb top position
|
||||||
|
let newThumbTop = (scrollStartY / maxScrollTop) * maxThumbTop + deltaY;
|
||||||
|
newThumbTop = Math.max(0, Math.min(newThumbTop, maxThumbTop));
|
||||||
|
|
||||||
|
// Calculate the new scroll position based on the thumb position
|
||||||
|
const newScrollTop = (newThumbTop / maxThumbTop) * maxScrollTop;
|
||||||
|
scrollContainer.scrollTop = newScrollTop;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', function(e) {
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
110
app/static/js/fullscreen.js
Normal file
110
app/static/js/fullscreen.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Add or remove the `fullscreen=1` URL parameter.
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
function updateUrlFullscreen(enabled) {
|
||||||
|
var url = new URL(window.location);
|
||||||
|
if (enabled) url.searchParams.set('fullscreen', '1');
|
||||||
|
else url.searchParams.delete('fullscreen');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a requestAnimationFrame loop that calls your recalc methods,
|
||||||
|
* and stops automatically when the header’s max-height transition ends.
|
||||||
|
*/
|
||||||
|
function recalcWhileCollapsing() {
|
||||||
|
const header = document.querySelector('header');
|
||||||
|
if (!header) return;
|
||||||
|
|
||||||
|
// 1) Start the RAF loop
|
||||||
|
let rafId;
|
||||||
|
const step = () => {
|
||||||
|
adjustScrollContainerHeight();
|
||||||
|
updateCustomScrollbar();
|
||||||
|
rafId = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
step();
|
||||||
|
|
||||||
|
// 2) Listen for the end of the max-height transition
|
||||||
|
function onEnd(e) {
|
||||||
|
if (e.propertyName === 'max-height') {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
header.removeEventListener('transitionend', onEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header.addEventListener('transitionend', onEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterFullscreen() {
|
||||||
|
document.body.classList.add('fullscreen');
|
||||||
|
setFullWidth(true);
|
||||||
|
updateUrlFullscreen(true);
|
||||||
|
|
||||||
|
// Nur jetzt sichtbar machen
|
||||||
|
const logo = document.getElementById('navbar_logo');
|
||||||
|
if (logo) {
|
||||||
|
logo.classList.add('visible');
|
||||||
|
}
|
||||||
|
recalcWhileCollapsing();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitFullscreen() {
|
||||||
|
document.body.classList.remove('fullscreen');
|
||||||
|
setFullWidth(false);
|
||||||
|
updateUrlFullscreen(false);
|
||||||
|
|
||||||
|
// Jetzt wieder verstecken
|
||||||
|
const logo = document.getElementById('navbar_logo');
|
||||||
|
if (logo) {
|
||||||
|
logo.classList.remove('visible');
|
||||||
|
}
|
||||||
|
recalcWhileCollapsing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between enter and exit fullscreen.
|
||||||
|
*/
|
||||||
|
function toggleFullscreen() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const isFull = params.get('fullscreen') === '1';
|
||||||
|
|
||||||
|
if (isFull) exitFullscreen();
|
||||||
|
else enterFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `fullscreen` flag from URL on load.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function initFullscreenFromUrl() {
|
||||||
|
return new URLSearchParams(window.location.search).get('fullscreen') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// On page load: apply fullwidth & fullscreen flags
|
||||||
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// first fullwidth
|
||||||
|
var wasFullWidth = initFullWidthFromUrl();
|
||||||
|
setFullWidth(wasFullWidth);
|
||||||
|
|
||||||
|
// now fullscreen
|
||||||
|
if (initFullscreenFromUrl()) {
|
||||||
|
enterFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror native F11/fullscreen API events
|
||||||
|
document.addEventListener('fullscreenchange', function() {
|
||||||
|
if (document.fullscreenElement) enterFullscreen();
|
||||||
|
else exitFullscreen();
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
var isUiFs = Math.abs(window.innerHeight - screen.height) < 2;
|
||||||
|
if (isUiFs) enterFullscreen();
|
||||||
|
else exitFullscreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose globally
|
||||||
|
window.fullscreen = enterFullscreen;
|
||||||
|
window.exitFullscreen = exitFullscreen;
|
||||||
|
window.toggleFullscreen = toggleFullscreen;
|
||||||
42
app/static/js/fullwidth.js
Normal file
42
app/static/js/fullwidth.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Toggles the .container class between .container and .container-fluid.
|
||||||
|
* @param {boolean} enabled – true = full width, false = normal.
|
||||||
|
*/
|
||||||
|
function setFullWidth(enabled) {
|
||||||
|
var el = document.querySelector('.container, .container-fluid');
|
||||||
|
if (!el) return;
|
||||||
|
if (enabled) {
|
||||||
|
el.classList.replace('container', 'container-fluid');
|
||||||
|
updateUrlFullWidth(true);
|
||||||
|
} else {
|
||||||
|
el.classList.replace('container-fluid', 'container');
|
||||||
|
updateUrlFullWidth(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the URL parameter `fullwidth` and applies full width if it's set.
|
||||||
|
* @returns {boolean} – current full‐width state
|
||||||
|
*/
|
||||||
|
function initFullWidthFromUrl() {
|
||||||
|
var isFull = new URLSearchParams(window.location.search).get('fullwidth') === '1';
|
||||||
|
setFullWidth(isFull);
|
||||||
|
return isFull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or removes the `fullwidth=1` URL parameter.
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
function updateUrlFullWidth(enabled) {
|
||||||
|
var url = new URL(window.location);
|
||||||
|
if (enabled) url.searchParams.set('fullwidth', '1');
|
||||||
|
else url.searchParams.delete('fullwidth');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose globally
|
||||||
|
window.setFullWidth = setFullWidth;
|
||||||
|
window.initFullWidthFromUrl = initFullWidthFromUrl;
|
||||||
|
window.updateUrlFullWidth = updateUrlFullWidth;
|
||||||
189
app/static/js/iframe.js
Normal file
189
app/static/js/iframe.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// Global variables to store elements and original state
|
||||||
|
let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
|
||||||
|
let currentIframeUrl = null;
|
||||||
|
|
||||||
|
// === Auto-open iframe if URL parameter is present ===
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const paramUrl = new URLSearchParams(window.location.search).get('iframe');
|
||||||
|
if (paramUrl) {
|
||||||
|
currentIframeUrl = paramUrl;
|
||||||
|
enterFullscreen();
|
||||||
|
openIframe(paramUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Synchronize the height of the iframe to match the scroll-container or main element
|
||||||
|
function syncIframeHeight() {
|
||||||
|
const iframe = mainElement.querySelector("iframe");
|
||||||
|
if (iframe) {
|
||||||
|
if (scrollbarContainer) {
|
||||||
|
// Prefer inline height, otherwise inline max-height
|
||||||
|
const inlineHeight = scrollbarContainer.style.height;
|
||||||
|
const inlineMax = scrollbarContainer.style.maxHeight;
|
||||||
|
const target = inlineHeight || inlineMax;
|
||||||
|
if (target) {
|
||||||
|
iframe.style.height = target;
|
||||||
|
} else {
|
||||||
|
iframe.style.height = mainElement.style.height;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
iframe.style.height = mainElement.style.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
|
||||||
|
function openIframe(url) {
|
||||||
|
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
|
||||||
|
var $customScroll = customScrollbar ? $(customScrollbar) : null;
|
||||||
|
var $main = $(mainElement);
|
||||||
|
|
||||||
|
// Original-Content ausblenden mit 1500 ms
|
||||||
|
var promises = [];
|
||||||
|
if ($container) promises.push($container.fadeOut(1500).promise());
|
||||||
|
if ($customScroll) promises.push($customScroll.fadeOut(1500).promise());
|
||||||
|
|
||||||
|
$.when.apply($, promises).done(function() {
|
||||||
|
// now that scroll areas are hidden, go fullscreen
|
||||||
|
enterFullscreen();
|
||||||
|
// create iframe if it doesn’t exist yet
|
||||||
|
var $iframe = $main.find('iframe');
|
||||||
|
if ($iframe.length === 0) {
|
||||||
|
originalMainStyle = $main.attr('style') || null;
|
||||||
|
$iframe = $('<iframe>', {
|
||||||
|
width: '100%',
|
||||||
|
frameborder: 0,
|
||||||
|
scrolling: 'auto'
|
||||||
|
}).css({ overflow: 'auto', display: 'none' });
|
||||||
|
$main.append($iframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quelle setzen und mit 1500 ms einblenden
|
||||||
|
$iframe
|
||||||
|
.attr('src', url)
|
||||||
|
.fadeIn(1500, function() {
|
||||||
|
syncIframeHeight();
|
||||||
|
observeIframeNavigation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// URL-State pushen
|
||||||
|
var newUrl = new URL(window.location);
|
||||||
|
newUrl.searchParams.set('iframe', url);
|
||||||
|
window.history.pushState({ iframe: url }, '', newUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the original <main> content and exit fullscreen.
|
||||||
|
*/
|
||||||
|
function restoreOriginal() {
|
||||||
|
// Exit fullscreen (collapse header/footer and run recalcs)
|
||||||
|
exitFullscreen();
|
||||||
|
|
||||||
|
// Replace <main> innerHTML with the snapshot we took on load
|
||||||
|
mainElement.innerHTML = originalContent;
|
||||||
|
|
||||||
|
// Reset any inline styles on mainElement
|
||||||
|
if (originalMainStyle !== null) {
|
||||||
|
mainElement.setAttribute('style', originalMainStyle);
|
||||||
|
} else {
|
||||||
|
mainElement.removeAttribute('style');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run height adjustments for scroll container & thumb
|
||||||
|
adjustScrollContainerHeight();
|
||||||
|
updateCustomScrollbar();
|
||||||
|
|
||||||
|
// Clear iframe state and URL param
|
||||||
|
currentIframeUrl = null;
|
||||||
|
history.replaceState(null, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize event listeners after DOM content is loaded
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
// Cache references to elements and original state
|
||||||
|
mainElement = document.querySelector("main");
|
||||||
|
originalContent = mainElement.innerHTML;
|
||||||
|
originalMainStyle = mainElement.getAttribute("style"); // may be null
|
||||||
|
container = document.querySelector(".container");
|
||||||
|
customScrollbar = document.getElementById("custom-scrollbar");
|
||||||
|
scrollbarContainer = container.querySelector(".scroll-container")
|
||||||
|
|
||||||
|
document.querySelectorAll(".js-restore").forEach(el => {
|
||||||
|
el.style.cursor = "pointer";
|
||||||
|
el.addEventListener("click", restoreOriginal);
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Close iframe & exit fullscreen when any .js-restore is clicked ===
|
||||||
|
document.querySelectorAll('.js-restore').forEach(el => {
|
||||||
|
el.style.cursor = 'pointer';
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
// first collapse header/footer and recalc container
|
||||||
|
exitFullscreen();
|
||||||
|
// then fade out and remove the iframe, fade content back
|
||||||
|
restoreOriginal();
|
||||||
|
// clear stored URL and reset browser address
|
||||||
|
currentIframeUrl = null;
|
||||||
|
history.replaceState(null, '', window.location.pathname);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the current iframe URL in a new browser tab.
|
||||||
|
*/
|
||||||
|
function openIframeInNewTab() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const iframeUrl = params.get('iframe');
|
||||||
|
if (iframeUrl) {
|
||||||
|
window.open(iframeUrl, '_blank');
|
||||||
|
} else {
|
||||||
|
alert('No iframe is currently open.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// expose globally so your template’s onclick can find it
|
||||||
|
window.openIframeInNewTab = openIframeInNewTab;
|
||||||
|
|
||||||
|
// Adjust iframe height on window resize
|
||||||
|
window.addEventListener('resize', syncIframeHeight);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe iframe location changes (Same-Origin only).
|
||||||
|
*/
|
||||||
|
function observeIframeNavigation() {
|
||||||
|
const iframe = mainElement.querySelector("iframe");
|
||||||
|
if (!iframe || !iframe.contentWindow) return;
|
||||||
|
|
||||||
|
let lastUrl = iframe.contentWindow.location.href;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
try {
|
||||||
|
const currentUrl = iframe.contentWindow.location.href;
|
||||||
|
if (currentUrl !== lastUrl) {
|
||||||
|
lastUrl = currentUrl;
|
||||||
|
const newUrl = new URL(window.location);
|
||||||
|
newUrl.searchParams.set('iframe', currentUrl);
|
||||||
|
window.history.replaceState({}, '', newUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Cross-origin – ignore
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember, open iframe, enter fullscreen, AND set the URL param immediately
|
||||||
|
document.querySelectorAll(".iframe-link").forEach(link => {
|
||||||
|
link.addEventListener("click", function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
currentIframeUrl = this.href;
|
||||||
|
|
||||||
|
enterFullscreen();
|
||||||
|
openIframe(currentIframeUrl);
|
||||||
|
|
||||||
|
// Update the browser URL right away
|
||||||
|
const newUrl = new URL(window.location);
|
||||||
|
newUrl.searchParams.set('iframe', currentIframeUrl);
|
||||||
|
window.history.replaceState({ iframe: currentIframeUrl }, '', newUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,6 +45,16 @@ function openDynamicPopup(subitem) {
|
|||||||
linkBox.classList.remove('d-none');
|
linkBox.classList.remove('d-none');
|
||||||
linkHref.href = subitem.url;
|
linkHref.href = subitem.url;
|
||||||
linkHref.innerText = subitem.description || "Open Link";
|
linkHref.innerText = subitem.description || "Open Link";
|
||||||
|
if (subitem.iframe) {
|
||||||
|
linkHref.classList.add('iframe');
|
||||||
|
// Attach an event listener that prevents the default behavior and
|
||||||
|
// opens the URL in an iframe when clicked.
|
||||||
|
linkHref.addEventListener('click', function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
openIframe(subitem.url);
|
||||||
|
closeAllModals()
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
linkBox.classList.add('d-none');
|
linkBox.classList.add('d-none');
|
||||||
linkHref.href = '#';
|
linkHref.href = '#';
|
||||||
|
|||||||
@@ -1,6 +1,58 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const menuItems = document.querySelectorAll('.nav-item.dropdown');
|
function getDirectChildByClass(item, className) {
|
||||||
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
|
return Array.from(item.children).find(child => child.classList?.contains(className));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMenu(item) {
|
||||||
|
return getDirectChildByClass(item, 'dropdown-menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToggle(item) {
|
||||||
|
return getDirectChildByClass(item, 'dropdown-toggle');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTopLevelDropdown(item) {
|
||||||
|
return (
|
||||||
|
item.classList.contains('nav-item') &&
|
||||||
|
(item.classList.contains('dropdown') || item.classList.contains('dropup'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseDirection(item) {
|
||||||
|
const rect = item.getBoundingClientRect();
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
if (spaceAbove > spaceBelow) {
|
||||||
|
item.classList.add('dropup');
|
||||||
|
item.classList.remove('dropdown');
|
||||||
|
} else {
|
||||||
|
item.classList.add('dropdown');
|
||||||
|
item.classList.remove('dropup');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDropdownInstances(root = document) {
|
||||||
|
const scope = root && root.querySelectorAll ? root : document;
|
||||||
|
scope
|
||||||
|
.querySelectorAll('.nav-item.dropdown > .dropdown-toggle, .nav-item.dropup > .dropdown-toggle')
|
||||||
|
.forEach(toggle => {
|
||||||
|
toggle.setAttribute('data-bs-toggle', 'dropdown');
|
||||||
|
if (!toggle.hasAttribute('aria-expanded')) {
|
||||||
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
if (window.bootstrap?.Dropdown) {
|
||||||
|
// Use Popper strategy: 'fixed' so the menu is positioned relative
|
||||||
|
// to the viewport and escapes ancestors with overflow:hidden
|
||||||
|
// (e.g. <header> which clips for the fullscreen-collapse animation).
|
||||||
|
window.bootstrap.Dropdown.getInstance(toggle)?.dispose();
|
||||||
|
new window.bootstrap.Dropdown(toggle, {
|
||||||
|
popperConfig(defaultBsPopperConfig) {
|
||||||
|
return { ...defaultBsPopperConfig, strategy: 'fixed' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addMenuEventListeners(items, isTopLevel) {
|
function addMenuEventListeners(items, isTopLevel) {
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
@@ -8,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
function onMouseEnter() {
|
function onMouseEnter() {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
openMenu(item, isTopLevel);
|
openMenu(item, isTopLevel, 'hover');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseLeave() {
|
function onMouseLeave() {
|
||||||
@@ -17,15 +69,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Öffnen beim Hovern
|
// Open on hover
|
||||||
item.addEventListener('mouseenter', onMouseEnter);
|
item.addEventListener('mouseenter', onMouseEnter);
|
||||||
|
|
||||||
// Verzögertes Schließen beim Verlassen
|
// Delayed close on mouse leave
|
||||||
item.addEventListener('mouseleave', onMouseLeave);
|
item.addEventListener('mouseleave', onMouseLeave);
|
||||||
|
|
||||||
// Öffnen und Position anpassen beim Klicken
|
// Open and adjust position on click
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
e.stopPropagation(); // Verhindert das Schließen von Menüs bei Klick
|
const toggle = getToggle(item);
|
||||||
|
const clickedToggle = toggle && (e.target === toggle || toggle.contains(e.target));
|
||||||
|
|
||||||
|
if (isTopLevel && !clickedToggle) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stopPropagation(); // Prevents menus from closing when clicking inside
|
||||||
|
if (isTopLevel) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (window.bootstrap?.Dropdown) {
|
||||||
|
if (item.dataset.openedBy === 'click') {
|
||||||
|
closeMenu(item);
|
||||||
|
} else if (getMenu(item)) {
|
||||||
|
item.dataset.openedBy = 'click';
|
||||||
|
item.classList.add('open');
|
||||||
|
chooseDirection(item);
|
||||||
|
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (item.classList.contains('open')) {
|
if (item.classList.contains('open')) {
|
||||||
closeMenu(item);
|
closeMenu(item);
|
||||||
} else {
|
} else {
|
||||||
@@ -35,46 +109,66 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOP_LEVEL_SELECTOR = '.nav-item.dropdown, .nav-item.dropup';
|
||||||
|
|
||||||
function addAllMenuEventListeners() {
|
function addAllMenuEventListeners() {
|
||||||
const updatedMenuItems = document.querySelectorAll('.nav-item.dropdown');
|
const updatedMenuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
|
||||||
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
|
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||||
addMenuEventListeners(updatedMenuItems, true);
|
addMenuEventListeners(updatedMenuItems, true);
|
||||||
addMenuEventListeners(updatedSubMenuItems, false);
|
addMenuEventListeners(updatedSubMenuItems, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureDropdownInstances();
|
||||||
addAllMenuEventListeners();
|
addAllMenuEventListeners();
|
||||||
|
|
||||||
// Globale Klick-Listener, um Menüs zu schließen, wenn außerhalb geklickt wird
|
// Global click listener to close menus when clicking outside
|
||||||
document.addEventListener('click', () => {
|
document.addEventListener('click', () => {
|
||||||
|
const menuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
|
||||||
|
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||||
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
|
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
|
||||||
});
|
});
|
||||||
|
|
||||||
function openMenu(item, isTopLevel) {
|
function openMenu(item, isTopLevel, openedBy = 'script') {
|
||||||
item.classList.add('open');
|
item.classList.add('open');
|
||||||
const submenu = item.querySelector('.dropdown-menu');
|
const submenu = getMenu(item);
|
||||||
if (submenu) {
|
if (!submenu) return;
|
||||||
submenu.style.display = 'block';
|
if (isTopLevel) {
|
||||||
submenu.style.opacity = '1';
|
item.dataset.openedBy = openedBy;
|
||||||
submenu.style.visibility = 'visible';
|
const toggle = getToggle(item);
|
||||||
adjustMenuPosition(submenu, item, isTopLevel);
|
if (toggle && window.bootstrap?.Dropdown) {
|
||||||
|
chooseDirection(item);
|
||||||
|
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
submenu.style.display = 'block';
|
||||||
|
submenu.style.opacity = '1';
|
||||||
|
submenu.style.visibility = 'visible';
|
||||||
|
adjustMenuPosition(submenu, item, isTopLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMenu(item) {
|
function closeMenu(item) {
|
||||||
item.classList.remove('open');
|
item.classList.remove('open');
|
||||||
const submenu = item.querySelector('.dropdown-menu');
|
delete item.dataset.openedBy;
|
||||||
if (submenu) {
|
const submenu = getMenu(item);
|
||||||
submenu.style.display = 'none';
|
if (!submenu) return;
|
||||||
submenu.style.opacity = '0';
|
if (isTopLevelDropdown(item)) {
|
||||||
submenu.style.visibility = 'hidden';
|
const toggle = getToggle(item);
|
||||||
|
if (toggle && window.bootstrap?.Dropdown) {
|
||||||
|
window.bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
submenu.style.display = 'none';
|
||||||
|
submenu.style.opacity = '0';
|
||||||
|
submenu.style.visibility = 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSmallScreen() {
|
function isSmallScreen() {
|
||||||
return window.innerWidth < 992; // Bootstrap-Breakpoint für 'lg'
|
return window.innerWidth < 992; // Bootstrap breakpoint for 'lg'
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
||||||
const rect = submenu.getBoundingClientRect();
|
const rect = submenu.getBoundingClientRect();
|
||||||
const parentRect = parent.getBoundingClientRect();
|
const parentRect = parent.getBoundingClientRect();
|
||||||
|
|
||||||
@@ -89,33 +183,33 @@ function adjustMenuPosition(submenu, parent, isTopLevel) {
|
|||||||
submenu.style.right = '';
|
submenu.style.right = '';
|
||||||
|
|
||||||
if (isTopLevel) {
|
if (isTopLevel) {
|
||||||
if (isSmallScreen && spaceBelow < spaceAbove) {
|
if (isSmallScreen() && spaceBelow < spaceAbove) {
|
||||||
// Für kleine Bildschirme: Menü direkt über dem Eltern-Element öffnen
|
// For small screens: Open menu directly above the parent element
|
||||||
submenu.style.top = 'auto';
|
submenu.style.top = 'auto';
|
||||||
submenu.style.bottom = `${parentRect.height}px`; // Direkt über dem Eltern-Element
|
submenu.style.bottom = `${parentRect.height}px`; // Directly above the parent element
|
||||||
|
}
|
||||||
|
// Top-level menu
|
||||||
|
else if (spaceBelow < spaceAbove) {
|
||||||
|
submenu.style.bottom = `${window.innerHeight - parentRect.bottom - parentRect.height}px`;
|
||||||
|
submenu.style.top = 'auto';
|
||||||
|
} else {
|
||||||
|
submenu.style.top = `${parentRect.height}px`;
|
||||||
|
submenu.style.bottom = 'auto';
|
||||||
}
|
}
|
||||||
// Top-Level-Menü
|
|
||||||
else if (spaceBelow < spaceAbove) {
|
|
||||||
submenu.style.bottom = `${window.innerHeight - parentRect.bottom - parentRect.height}px`;
|
|
||||||
submenu.style.top = 'auto';
|
|
||||||
} else {
|
|
||||||
submenu.style.top = `${parentRect.height}px`;
|
|
||||||
submenu.style.bottom = 'auto';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Submenü
|
// Submenu
|
||||||
const prefersRight = spaceRight >= spaceLeft;
|
const prefersRight = spaceRight >= spaceLeft;
|
||||||
submenu.style.left = prefersRight ? '100%' : 'auto';
|
submenu.style.left = prefersRight ? '100%' : 'auto';
|
||||||
submenu.style.right = prefersRight ? 'auto' : '100%';
|
submenu.style.right = prefersRight ? 'auto' : '100%';
|
||||||
|
|
||||||
// Nach oben öffnen, wenn unten kein Platz ist
|
// Open upwards if there's no space below
|
||||||
if (spaceBelow < spaceAbove) {
|
if (spaceBelow < spaceAbove) {
|
||||||
submenu.style.bottom = `0`;
|
submenu.style.bottom = `0`;
|
||||||
submenu.style.top = `auto`;
|
submenu.style.top = `auto`;
|
||||||
} else {
|
} else {
|
||||||
submenu.style.top = `0`;
|
submenu.style.top = `0`;
|
||||||
submenu.style.bottom = '${parentRect.height}px';
|
submenu.style.bottom = `${parentRect.height}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Initialisiert alle Tooltips auf der Seite
|
// Initializes all tooltips on the page
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
|
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
|
||||||
|
|||||||
@@ -1,32 +1,58 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{{company.titel}}</title>
|
<title>{{platform.titel}}</title>
|
||||||
<meta charset="utf-8" >
|
<meta charset="utf-8" >
|
||||||
<link rel="icon" type="image/x-icon" href="{{company.favicon.cache}}">
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/x-icon"
|
||||||
|
href="{% if platform.favicon.cache %}{{ url_for('static', filename=platform.favicon.cache) }}{% endif %}"
|
||||||
|
>
|
||||||
<!-- Bootstrap CSS only -->
|
<!-- Bootstrap CSS only -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
|
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||||
<!-- Bootstrap JavaScript Bundle with Popper -->
|
<!-- Bootstrap JavaScript Bundle with Popper -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
|
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
||||||
<!-- Bootstrap Icons -->
|
<!-- Bootstrap Icons -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/font/bootstrap-icons.css') }}">
|
||||||
<!-- Fontawesome -->
|
<!-- Fontawesome -->
|
||||||
<script src="https://kit.fontawesome.com/56f96da298.js" crossorigin="anonymous"></script>
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fontawesome/css/all.min.css') }}">
|
||||||
<!-- Markdown -->
|
<!-- Markdown -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<script src="{{ url_for('static', filename='vendor/marked/marked.min.js') }}"></script>
|
||||||
<link rel="stylesheet" href="static/css/default.css">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom_scrollbar.css') }}">
|
||||||
|
<!-- JQuery -->
|
||||||
|
<script src="{{ url_for('static', filename='vendor/jquery/jquery.min.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body
|
||||||
|
{% if apod_bg %}
|
||||||
|
style="
|
||||||
|
background-image: url('{{ apod_bg }}');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="header">
|
<header class="header js-restore">
|
||||||
<img src="{{company.logo.cache}}" alt="logo"/>
|
<img
|
||||||
<h1>{{company.titel}}</h1>
|
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
||||||
<h2>{{company.subtitel}}</h2>
|
alt="logo"
|
||||||
<br />
|
/>
|
||||||
|
<h1>{{platform.titel}}</h1>
|
||||||
|
<h2>{{platform.subtitel}}</h2>
|
||||||
</header>
|
</header>
|
||||||
{% set menu_type = "header" %}
|
{% set menu_type = "header" %}
|
||||||
{% include "moduls/navigation.html.j2"%}
|
{% include "moduls/navigation.html.j2"%}
|
||||||
{% block content %}{% endblock %}
|
<main id="main">
|
||||||
|
<div class="scroll-container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<!-- Custom scrollbar element fixiert am rechten Rand -->
|
||||||
|
<div id="custom-scrollbar">
|
||||||
|
<div id="scroll-thumb"></div>
|
||||||
|
</div>
|
||||||
{% set menu_type = "footer" %}
|
{% set menu_type = "footer" %}
|
||||||
{% include "moduls/navigation.html.j2" %}
|
{% include "moduls/navigation.html.j2" %}
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
@@ -34,14 +60,22 @@
|
|||||||
<p itemprop="name">{{ company.titel }} <br />
|
<p itemprop="name">{{ company.titel }} <br />
|
||||||
{{ company.subtitel }}</p>
|
{{ company.subtitel }}</p>
|
||||||
<span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span>
|
<span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span>
|
||||||
<p><a href="{{company.imprint_url}}"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p>
|
<p><a href="{{company.imprint_url}}" class="iframe-link"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<!-- Include modal -->
|
<!-- Include modal -->
|
||||||
{% include "moduls/modal.html.j2" %}
|
{% include "moduls/modal.html.j2" %}
|
||||||
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
|
{% for name in [
|
||||||
<script src="{{ url_for('static', filename='js/navigation.js') }}"></script>
|
'modal',
|
||||||
<script src="{{ url_for('static', filename='js/tooltip.js') }}"></script>
|
'navigation',
|
||||||
|
'tooltip',
|
||||||
|
'container',
|
||||||
|
'fullwidth',
|
||||||
|
'fullscreen',
|
||||||
|
'iframe',
|
||||||
|
] %}
|
||||||
|
<script src="{{ url_for('static', filename='js/' ~ name ~ '.js') }}"></script>
|
||||||
|
{% endfor %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,17 +1,34 @@
|
|||||||
<div class="card-column col-lg-3 col-md-6 col-12">
|
<div class="card-column {{ lg_class }} {{ md_class }} col-12">
|
||||||
<div class="card h-100 d-flex flex-column">
|
<div class="card h-100 d-flex flex-column">
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<div class="card-img-top">
|
<div class="card-img-top">
|
||||||
<img src="{{ card.icon.cache }}" alt="{{ card.title }}" style="width: 100px; height: auto;">
|
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
|
||||||
</div>
|
{{ include_svg(card.icon.cache) }}
|
||||||
<hr />
|
{% elif card.icon.cache %}
|
||||||
<h3 class="card-title">{{ card.title }}</h3>
|
<img
|
||||||
<p class="card-text">{{ card.text }}</p>
|
src="{{ url_for('static', filename=card.icon.cache) }}"
|
||||||
{% if card.url %}
|
alt="{{ card.title }}"
|
||||||
<a href="{{ card.url }}" class="mt-auto btn btn-light stretched-link" ><i class="fa-solid fa-globe"></i> {{ card.link_text }}</a>
|
style="width:100px; height:auto;"
|
||||||
{% else %}
|
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
|
||||||
<i class="fa-solid fa-hourglass"></i> {{ card.link_text }}
|
{% if card.icon.class %}
|
||||||
{% endif %}
|
<i class="{{ card.icon.class }}" style="display:none;"></i>
|
||||||
</div>
|
{% endif %}
|
||||||
|
{% elif card.icon.class %}
|
||||||
|
<i class="{{ card.icon.class }}"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<h3 class="card-title">{{ card.title }}</h3>
|
||||||
|
<p class="card-text">{{ card.text }}</p>
|
||||||
|
{% if card.url %}
|
||||||
|
<a
|
||||||
|
href="{{ card.url }}"
|
||||||
|
class="mt-auto btn btn-light stretched-link {% if card.iframe %}iframe-link{% endif %}">
|
||||||
|
<i class="fa-solid fa-globe"></i> {{ card.link_text }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<i class="fa-solid fa-hourglass"></i> {{ card.link_text }}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -8,65 +8,96 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
<!-- Template for children -->
|
<!-- Template for children -->
|
||||||
{% macro render_children(children) %}
|
{% macro render_children(children) %}
|
||||||
{% for children in children %}
|
{% for child in children %}
|
||||||
{% if children.children %}
|
{% if child.children %}
|
||||||
<li class="dropdown-submenu position-relative">
|
<li class="dropdown-submenu position-relative">
|
||||||
<a class="dropdown-item dropdown-toggle" title="{{ children.description }}">
|
<a class="dropdown-item dropdown-toggle" title="{{ child.description }}">
|
||||||
{{ render_icon_and_name(children) }}
|
{{ render_icon_and_name(child) }}
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
{{ render_children(children.children) }}
|
{{ render_children(child.children) }}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% elif children.identifier or children.warning or children.info %}
|
|
||||||
<li>
|
{% elif child.identifier or child.warning or child.info %}
|
||||||
<a class="dropdown-item" onclick='openDynamicPopup({{ children|tojson|safe }})' data-bs-toggle="tooltip" title="{{ children.description }}">
|
<li>
|
||||||
{{ render_icon_and_name(children) }}
|
<a class="dropdown-item"
|
||||||
</a>
|
onclick='openDynamicPopup({{ child|tojson|safe }})'
|
||||||
</li>
|
data-bs-toggle="tooltip"
|
||||||
{% else %}
|
title="{{ child.description }}">
|
||||||
<li>
|
{{ render_icon_and_name(child) }}
|
||||||
<a class="dropdown-item" href="{{ children.url }}" target="{{ children.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ children.description }}">
|
</a>
|
||||||
{{ render_icon_and_name(children) }}
|
</li>
|
||||||
</a>
|
|
||||||
</li>
|
{% else %}
|
||||||
{% endif %}
|
<li>
|
||||||
{% endfor %}
|
<a class="dropdown-item {% if child.iframe %}iframe-link{% endif %}"
|
||||||
|
{% if child.onclick %}
|
||||||
|
onclick="{{ child.onclick }}"
|
||||||
|
{% else %}
|
||||||
|
href="{{ child.url }}"
|
||||||
|
{% endif %}
|
||||||
|
target="{{ child.target|default('_blank') }}"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="{{ child.description }}">
|
||||||
|
{{ render_icon_and_name(child) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<!-- Navigation Bar -->
|
<!-- Navigation Bar -->
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
<nav class="navbar navbar-expand-lg navbar-light bg-light menu-{{menu_type}} mb-0">
|
||||||
<div class="container-fluid">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav{{menu_type}}" aria-controls="navbarNav{{menu_type}}" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav{{menu_type}}" aria-controls="navbarNav{{menu_type}}" aria-expanded="false" aria-label="Toggle navigation">
|
<span class="navbar-toggler-icon"></span>
|
||||||
<span class="navbar-toggler-icon"></span>
|
</button>
|
||||||
</button>
|
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
|
||||||
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
|
{% if menu_type == "header" %}
|
||||||
<ul class="navbar-nav {% if menu_type == 'header' %}ms-auto{% endif %} btn-group">
|
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
|
||||||
{% for item in navigation[menu_type].children %}
|
<img
|
||||||
{% if item.url %}
|
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
||||||
<!-- Single Item -->
|
alt="{{ platform.titel }}"
|
||||||
<li class="nav-item">
|
class="d-inline-block align-text-top"
|
||||||
<a class="nav-link btn btn-light" href="{{ item.url }}" target="{{ item.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ item.description }}">
|
style="height:2rem">
|
||||||
|
<div class="ms-2 d-flex flex-column">
|
||||||
|
<span class="fs-4 fw-bold mb-0">{{ platform.titel }}</span>
|
||||||
|
{# <small class="fs-7 text-muted">{{ platform.subtitel }}</small> #}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<ul class="navbar-nav {% if menu_type == 'header' %}ms-auto{% endif %} btn-group">
|
||||||
|
{% for item in navigation[menu_type].children %}
|
||||||
|
{% if item.url or item.onclick %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link btn btn-light {% if item.iframe %}iframe-link{% endif %}"
|
||||||
|
{% if item.onclick %}
|
||||||
|
onclick="{{ item.onclick }}"
|
||||||
|
{% else %}
|
||||||
|
href="{{ item.url }}"
|
||||||
|
{% endif %}
|
||||||
|
target="{{ item.target|default('_blank') }}"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="{{ item.description }}">
|
||||||
|
{{ render_icon_and_name(item) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<!-- Dropdown Menu -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false">
|
||||||
|
{% if item.icon is defined and item.icon.class is defined %}
|
||||||
{{ render_icon_and_name(item) }}
|
{{ render_icon_and_name(item) }}
|
||||||
</a>
|
{% else %}
|
||||||
</li>
|
<p>Missing icon in item: {{ item }}</p>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<!-- Dropdown Menu -->
|
</a>
|
||||||
<li class="nav-item dropdown">
|
<ul class="dropdown-menu">
|
||||||
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-display="dynamic" aria-expanded="false">
|
{{ render_children(item.children) }}
|
||||||
{% if item.icon is defined and item.icon.class is defined %}
|
</ul>
|
||||||
{{ render_icon_and_name(item) }}
|
</li>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<p>Missing icon in item: {{ item }}</p>
|
{% endfor %}
|
||||||
{% endif %}
|
</ul>
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
{{ render_children(item.children) }}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for card in cards %}
|
{% for card in cards %}
|
||||||
|
{% set index = loop.index0 %}
|
||||||
|
{% set lg_class = lg_classes[index] %}
|
||||||
|
{% set md_class = md_classes[index] %}
|
||||||
{% include "moduls/card.html.j2" %}
|
{% include "moduls/card.html.j2" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1
app/utils/__init__.py
Normal file
1
app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Utilities used by the Portfolio UI web application."""
|
||||||
@@ -1,66 +1,47 @@
|
|||||||
import os
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
class CacheManager:
|
class CacheManager:
|
||||||
"""
|
|
||||||
A class to manage caching of files, including creating temporary directories
|
|
||||||
and caching files locally with hashed filenames.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cache_dir="static/cache"):
|
def __init__(self, cache_dir="static/cache"):
|
||||||
"""
|
|
||||||
Initialize the CacheManager with a cache directory.
|
|
||||||
|
|
||||||
:param cache_dir: The directory where cached files will be stored.
|
|
||||||
"""
|
|
||||||
self.cache_dir = cache_dir
|
self.cache_dir = cache_dir
|
||||||
self._ensure_cache_dir_exists()
|
self._ensure_cache_dir_exists()
|
||||||
|
|
||||||
def _ensure_cache_dir_exists(self):
|
def _ensure_cache_dir_exists(self):
|
||||||
"""
|
os.makedirs(self.cache_dir, exist_ok=True)
|
||||||
Ensure the cache directory exists. If it doesn't, create it.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(self.cache_dir):
|
|
||||||
os.makedirs(self.cache_dir)
|
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
"""
|
|
||||||
Clear all files in the cache directory.
|
|
||||||
"""
|
|
||||||
if os.path.exists(self.cache_dir):
|
if os.path.exists(self.cache_dir):
|
||||||
for filename in os.listdir(self.cache_dir):
|
for filename in os.listdir(self.cache_dir):
|
||||||
file_path = os.path.join(self.cache_dir, filename)
|
path = os.path.join(self.cache_dir, filename)
|
||||||
if os.path.isfile(file_path):
|
if os.path.isfile(path):
|
||||||
os.remove(file_path)
|
os.remove(path)
|
||||||
|
|
||||||
def cache_file(self, file_url):
|
def cache_file(self, file_url):
|
||||||
"""
|
hash_suffix = hashlib.blake2s(
|
||||||
Download a file and store it locally in the cache directory with a hashed filename.
|
file_url.encode("utf-8"),
|
||||||
|
digest_size=8,
|
||||||
|
).hexdigest()
|
||||||
|
parts = file_url.rstrip("/").split("/")
|
||||||
|
base = parts[-2] if parts[-1] == "download" else parts[-1]
|
||||||
|
|
||||||
:param file_url: The URL of the file to cache.
|
try:
|
||||||
:return: The local path of the cached file.
|
resp = requests.get(file_url, stream=True, timeout=5)
|
||||||
"""
|
resp.raise_for_status()
|
||||||
# Generate a hashed filename based on the URL
|
except requests.RequestException:
|
||||||
hash_object = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8)
|
return None
|
||||||
hash_suffix = hash_object.hexdigest()
|
|
||||||
|
|
||||||
# Determine the base name for the file
|
content_type = resp.headers.get("Content-Type", "")
|
||||||
splitted_file_url = file_url.split("/")
|
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png"
|
||||||
base_name = splitted_file_url[-2] if splitted_file_url[-1] == "download" else splitted_file_url[-1]
|
filename = f"{base}_{hash_suffix}{ext}"
|
||||||
|
|
||||||
# Construct the full path for the cached file
|
|
||||||
filename = f"{base_name}_{hash_suffix}.png"
|
|
||||||
full_path = os.path.join(self.cache_dir, filename)
|
full_path = os.path.join(self.cache_dir, filename)
|
||||||
|
|
||||||
# If the file already exists, return the cached path
|
if not os.path.exists(full_path):
|
||||||
if os.path.exists(full_path):
|
with open(full_path, "wb") as f:
|
||||||
return full_path
|
for chunk in resp.iter_content(1024):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
# Download the file and save it locally
|
return f"cache/{filename}"
|
||||||
response = requests.get(file_url, stream=True)
|
|
||||||
if response.status_code == 200:
|
|
||||||
with open(full_path, "wb") as file:
|
|
||||||
for chunk in response.iter_content(1024):
|
|
||||||
file.write(chunk)
|
|
||||||
return full_path
|
|
||||||
|
|||||||
42
app/utils/compute_card_classes.py
Normal file
42
app/utils/compute_card_classes.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
def compute_card_classes(cards):
|
||||||
|
num_cards = len(cards)
|
||||||
|
lg_classes = []
|
||||||
|
if num_cards < 3:
|
||||||
|
if num_cards == 2:
|
||||||
|
lg_classes = ["col-lg-6", "col-lg-6"]
|
||||||
|
else:
|
||||||
|
lg_classes = ["col-lg-12"]
|
||||||
|
elif num_cards % 4 == 0:
|
||||||
|
lg_classes = ["col-lg-3"] * num_cards
|
||||||
|
elif num_cards % 3 == 0:
|
||||||
|
lg_classes = ["col-lg-4"] * num_cards
|
||||||
|
elif num_cards % 2 == 0:
|
||||||
|
lg_classes = ["col-lg-6"] * num_cards
|
||||||
|
else:
|
||||||
|
# For complex cases (e.g., 5, 7, 11) – Ensure at least 3 per row
|
||||||
|
for i in range(num_cards):
|
||||||
|
if num_cards % 4 == 3:
|
||||||
|
if i < 3:
|
||||||
|
lg_classes.append("col-lg-4")
|
||||||
|
else:
|
||||||
|
lg_classes.append("col-lg-3")
|
||||||
|
elif num_cards % 4 == 1:
|
||||||
|
if i < 2:
|
||||||
|
lg_classes.append("col-lg-6")
|
||||||
|
elif i < 5:
|
||||||
|
lg_classes.append("col-lg-4")
|
||||||
|
else:
|
||||||
|
lg_classes.append("col-lg-3")
|
||||||
|
elif num_cards % 3 == 2:
|
||||||
|
if i < 2:
|
||||||
|
lg_classes.append("col-lg-6")
|
||||||
|
else:
|
||||||
|
lg_classes.append("col-lg-4")
|
||||||
|
# Use a full-width last card on medium screens only when the total count is odd.
|
||||||
|
md_classes = []
|
||||||
|
for i in range(num_cards):
|
||||||
|
if num_cards % 2 == 0 or i < num_cards - 1:
|
||||||
|
md_classes.append("col-md-6")
|
||||||
|
else:
|
||||||
|
md_classes.append("col-md-12")
|
||||||
|
return lg_classes, md_classes
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
from pprint import pprint
|
|
||||||
class ConfigurationResolver:
|
class ConfigurationResolver:
|
||||||
"""
|
"""
|
||||||
A class to resolve `link` entries in a nested configuration structure.
|
A class to resolve `link` entries in a nested configuration structure.
|
||||||
@@ -14,22 +13,9 @@ class ConfigurationResolver:
|
|||||||
"""
|
"""
|
||||||
self._recursive_resolve(self.config, self.config)
|
self._recursive_resolve(self.config, self.config)
|
||||||
|
|
||||||
def __load_children(self,path):
|
|
||||||
"""
|
|
||||||
Check if explicitly children should be loaded and not parent
|
|
||||||
"""
|
|
||||||
return path.split('.').pop() == "children"
|
|
||||||
|
|
||||||
def _replace_in_dict_by_dict(self, dict_origine, old_key, new_dict):
|
|
||||||
if old_key in dict_origine:
|
|
||||||
# Entferne den alten Key
|
|
||||||
old_value = dict_origine.pop(old_key)
|
|
||||||
# Füge die neuen Key-Value-Paare hinzu
|
|
||||||
dict_origine.update(new_dict)
|
|
||||||
|
|
||||||
def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
|
def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
|
||||||
index = list_origine.index(old_element)
|
index = list_origine.index(old_element)
|
||||||
list_origine[index:index+1] = new_elements
|
list_origine[index : index + 1] = new_elements
|
||||||
|
|
||||||
def _replace_element_in_list(self, list_origine, old_element, new_element):
|
def _replace_element_in_list(self, list_origine, old_element, new_element):
|
||||||
index = list_origine.index(old_element)
|
index = list_origine.index(old_element)
|
||||||
@@ -43,27 +29,43 @@ class ConfigurationResolver:
|
|||||||
for key, value in list(current_config.items()):
|
for key, value in list(current_config.items()):
|
||||||
if key == "children":
|
if key == "children":
|
||||||
if value is None or not isinstance(value, list):
|
if value is None or not isinstance(value, list):
|
||||||
raise ValueError(f"Expected 'children' to be a list, but got {type(value).__name__} instead.")
|
raise ValueError(
|
||||||
|
"Expected 'children' to be a list, but got "
|
||||||
|
f"{type(value).__name__} instead."
|
||||||
|
)
|
||||||
for item in value:
|
for item in value:
|
||||||
if "link" in item:
|
if "link" in item:
|
||||||
loaded_link = self._find_entry(root_config, self._mapped_key(item['link']), False)
|
loaded_link = self._find_entry(
|
||||||
|
root_config,
|
||||||
|
self._mapped_key(item["link"]),
|
||||||
|
False,
|
||||||
|
)
|
||||||
if isinstance(loaded_link, list):
|
if isinstance(loaded_link, list):
|
||||||
self._replace_in_list_by_list(value,item,loaded_link)
|
self._replace_in_list_by_list(value, item, loaded_link)
|
||||||
else:
|
else:
|
||||||
self._replace_element_in_list(value,item,loaded_link)
|
self._replace_element_in_list(value, item, loaded_link)
|
||||||
else:
|
else:
|
||||||
self._recursive_resolve(value, root_config)
|
self._recursive_resolve(value, root_config)
|
||||||
elif key == "link":
|
elif key == "link":
|
||||||
try:
|
try:
|
||||||
loaded = self._find_entry(root_config, self._mapped_key(value), False)
|
loaded = self._find_entry(
|
||||||
|
root_config, self._mapped_key(value), False
|
||||||
|
)
|
||||||
if isinstance(loaded, list) and len(loaded) > 2:
|
if isinstance(loaded, list) and len(loaded) > 2:
|
||||||
loaded = self._find_entry(root_config, self._mapped_key(value), False)
|
loaded = self._find_entry(
|
||||||
|
root_config, self._mapped_key(value), False
|
||||||
|
)
|
||||||
current_config.clear()
|
current_config.clear()
|
||||||
current_config.update(loaded)
|
current_config.update(loaded)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Error resolving link '{value}': {str(e)}. "
|
f"Error resolving link '{value}': {str(e)}. "
|
||||||
f"Current part: {key}, Current config: {current_config}" + (f", Loaded: {loaded}" if 'loaded' in locals() or 'loaded' in globals() else "")
|
f"Current part: {key}, Current config: {current_config}"
|
||||||
|
+ (
|
||||||
|
f", Loaded: {loaded}"
|
||||||
|
if "loaded" in locals() or "loaded" in globals()
|
||||||
|
else ""
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._recursive_resolve(value, root_config)
|
self._recursive_resolve(value, root_config)
|
||||||
@@ -71,63 +73,74 @@ class ConfigurationResolver:
|
|||||||
for item in current_config:
|
for item in current_config:
|
||||||
self._recursive_resolve(item, root_config)
|
self._recursive_resolve(item, root_config)
|
||||||
|
|
||||||
def _get_children(self,current):
|
def _get_children(self, current):
|
||||||
if isinstance(current, dict) and ("children" in current and current["children"]):
|
if isinstance(current, dict) and (
|
||||||
|
"children" in current and current["children"]
|
||||||
|
):
|
||||||
current = current["children"]
|
current = current["children"]
|
||||||
return current
|
return current
|
||||||
|
|
||||||
def _mapped_key(self,name):
|
def _mapped_key(self, name):
|
||||||
return name.replace(" ", "").lower()
|
return name.replace(" ", "").lower()
|
||||||
|
|
||||||
def _find_by_name(self,current, part):
|
def _find_by_name(self, current, part):
|
||||||
return next(
|
return next(
|
||||||
(item for item in current if isinstance(item, dict) and self._mapped_key(item.get("name", "")) == part),
|
(
|
||||||
None
|
item
|
||||||
)
|
for item in current
|
||||||
|
if isinstance(item, dict)
|
||||||
|
and self._mapped_key(item.get("name", "")) == part
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
def _find_entry(self, config, path, children):
|
def _find_entry(self, config, path, children):
|
||||||
"""
|
"""
|
||||||
Finds an entry in the configuration by a dot-separated path.
|
Finds an entry in the configuration by a dot-separated path.
|
||||||
Supports both dictionaries and lists with `children` navigation.
|
Supports both dictionaries and lists with `children` navigation.
|
||||||
"""
|
"""
|
||||||
parts = path.split('.')
|
parts = path.split(".")
|
||||||
current = config
|
current = config
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if isinstance(current, list):
|
if isinstance(current, list):
|
||||||
# If children explicit declared just load children
|
|
||||||
if part != "children":
|
if part != "children":
|
||||||
# Look for a matching name in the list
|
found = self._find_by_name(current, part)
|
||||||
found = self._find_by_name(current,part)
|
|
||||||
if found:
|
if found:
|
||||||
current = found
|
current = found
|
||||||
print(
|
print(
|
||||||
f"Matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
f"Matching entry for '{part}' in list. Path so far: "
|
||||||
|
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||||
f"Current list: {current}"
|
f"Current list: {current}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"No matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
f"No matching entry for '{part}' in list. Path so far: "
|
||||||
|
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||||
f"Current list: {current}"
|
f"Current list: {current}"
|
||||||
)
|
)
|
||||||
elif isinstance(current, dict):
|
elif isinstance(current, dict):
|
||||||
# Case-insensitive dictionary lookup
|
|
||||||
key = next((k for k in current if self._mapped_key(k) == part), None)
|
key = next((k for k in current if self._mapped_key(k) == part), None)
|
||||||
# If no fitting key was found search in the children
|
|
||||||
if key is None:
|
if key is None:
|
||||||
# The following line seems buggy; Why is children loaded allways and not just when children is set?
|
if "children" not in current:
|
||||||
current = self._find_by_name(current["children"],part)
|
raise KeyError(
|
||||||
|
"No 'children' found in current dictionary. Path so far: "
|
||||||
|
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||||
|
f"Current dictionary: {current}"
|
||||||
|
)
|
||||||
|
current = self._find_by_name(current["children"], part)
|
||||||
|
|
||||||
if not current:
|
if not current:
|
||||||
raise KeyError(
|
raise KeyError(
|
||||||
f"Key '{part}' not found in dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
f"Key '{part}' not found in dictionary. Path so far: "
|
||||||
|
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||||
f"Current dictionary: {current}"
|
f"Current dictionary: {current}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
current = current[key]
|
current = current[key]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid path segment '{part}'. Current type: {type(current)}. "
|
f"Invalid path segment '{part}'. Current type: {type(current)}. "
|
||||||
f"Path so far: {' > '.join(parts[:parts.index(part)+1])}"
|
f"Path so far: {' > '.join(parts[: parts.index(part) + 1])}"
|
||||||
)
|
)
|
||||||
if children:
|
if children:
|
||||||
current = self._get_children(current)
|
current = self._get_children(current)
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
landingpage:
|
portfolio:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: application-landingpage
|
container_name: portfolio
|
||||||
container_name: landingpage
|
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "${PORT:-5000}:${PORT:-5000}"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app
|
- ./app:/app
|
||||||
|
- node_modules:/app/node_modules
|
||||||
|
- vendor:/app/static/vendor
|
||||||
|
# Run `npm install` on every container start so the named volumes
|
||||||
|
# reflect the current package.json (postinstall regenerates vendor/).
|
||||||
|
command: sh -c "npm install --prefix /app --no-audit --no-fund && python app.py"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules:
|
||||||
|
vendor:
|
||||||
|
|||||||
2
env.example
Normal file
2
env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PORT=5001
|
||||||
|
FLASK_ENV=production
|
||||||
79
main.py
Executable file
79
main.py
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
main.py - Proxy to Makefile targets for managing the Portfolio CMS Docker application.
|
||||||
|
Automatically generates CLI commands based on the Makefile definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
|
||||||
|
|
||||||
|
|
||||||
|
def load_targets(makefile_path):
|
||||||
|
"""
|
||||||
|
Parse the Makefile to extract targets and their help comments.
|
||||||
|
Assumes each target is defined as 'name:' and the following line that starts
|
||||||
|
with '\t#' provides its help text.
|
||||||
|
"""
|
||||||
|
targets = []
|
||||||
|
pattern = re.compile(r"^([A-Za-z0-9_\-]+):")
|
||||||
|
with open(makefile_path, "r", encoding="utf-8") as handle:
|
||||||
|
lines = handle.readlines()
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
m = pattern.match(line)
|
||||||
|
if m:
|
||||||
|
name = m.group(1)
|
||||||
|
help_text = ""
|
||||||
|
if idx + 1 < len(lines):
|
||||||
|
next_line = lines[idx + 1].lstrip()
|
||||||
|
if next_line.startswith("#"):
|
||||||
|
help_text = next_line.lstrip("# ").strip()
|
||||||
|
targets.append((name, help_text))
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(command, dry_run=False):
|
||||||
|
"""Utility to run shell commands."""
|
||||||
|
print(f"Executing: {' '.join(command)}")
|
||||||
|
if dry_run:
|
||||||
|
print("Dry run enabled: command not executed.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
subprocess.check_call(command)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error: Command failed with exit code {e.returncode}")
|
||||||
|
sys.exit(e.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="CLI proxy to Makefile targets for Portfolio CMS Docker app"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Print the generated Make command without executing it.",
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(
|
||||||
|
title="Available commands",
|
||||||
|
dest="command",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
targets = load_targets(MAKEFILE_PATH)
|
||||||
|
for name, help_text in targets:
|
||||||
|
sp = subparsers.add_parser(name, help=help_text)
|
||||||
|
sp.set_defaults(target=name)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
cmd = ["make", args.target]
|
||||||
|
run_command(cmd, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
44
pyproject.toml
Normal file
44
pyproject.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "portfolio-ui"
|
||||||
|
version = "1.2.0"
|
||||||
|
description = "A lightweight YAML-driven portfolio and landing-page generator."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"flask",
|
||||||
|
"pyyaml",
|
||||||
|
"requests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"bandit",
|
||||||
|
"pip-audit",
|
||||||
|
"ruff",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["main"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app", "app.*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
app = [
|
||||||
|
"config.sample.yaml",
|
||||||
|
"templates/**/*.j2",
|
||||||
|
"static/css/*.css",
|
||||||
|
"static/js/*.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 88
|
||||||
|
extend-exclude = ["app/static/cache", "build"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I"]
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
54
tests/integration/test_python_packaging.py
Normal file
54
tests/integration/test_python_packaging.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestPythonPackaging(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
self.pyproject_path = self.repo_root / "pyproject.toml"
|
||||||
|
|
||||||
|
with self.pyproject_path.open("rb") as handle:
|
||||||
|
self.pyproject = tomllib.load(handle)
|
||||||
|
|
||||||
|
def test_pyproject_defines_build_system_and_runtime_dependencies(self):
|
||||||
|
build_system = self.pyproject["build-system"]
|
||||||
|
project = self.pyproject["project"]
|
||||||
|
|
||||||
|
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
|
||||||
|
self.assertIn("setuptools>=69", build_system["requires"])
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(project["dependencies"]),
|
||||||
|
{"flask", "pyyaml", "requests"},
|
||||||
|
)
|
||||||
|
self.assertEqual(project["requires-python"], ">=3.12")
|
||||||
|
|
||||||
|
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
|
||||||
|
project = self.pyproject["project"]
|
||||||
|
setuptools_config = self.pyproject["tool"]["setuptools"]
|
||||||
|
package_find = setuptools_config["packages"]["find"]
|
||||||
|
package_data = setuptools_config["package-data"]["app"]
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(project["optional-dependencies"]["dev"]),
|
||||||
|
{"bandit", "pip-audit", "ruff"},
|
||||||
|
)
|
||||||
|
self.assertEqual(setuptools_config["py-modules"], ["main"])
|
||||||
|
self.assertEqual(package_find["include"], ["app", "app.*"])
|
||||||
|
self.assertIn("config.sample.yaml", package_data)
|
||||||
|
self.assertIn("templates/**/*.j2", package_data)
|
||||||
|
self.assertIn("static/css/*.css", package_data)
|
||||||
|
self.assertIn("static/js/*.js", package_data)
|
||||||
|
|
||||||
|
def test_legacy_requirements_files_are_removed(self):
|
||||||
|
self.assertFalse((self.repo_root / "requirements.txt").exists())
|
||||||
|
self.assertFalse((self.repo_root / "requirements-dev.txt").exists())
|
||||||
|
self.assertFalse((self.repo_root / "app" / "requirements.txt").exists())
|
||||||
|
|
||||||
|
def test_package_init_files_exist(self):
|
||||||
|
self.assertTrue((self.repo_root / "app" / "__init__.py").is_file())
|
||||||
|
self.assertTrue((self.repo_root / "app" / "utils" / "__init__.py").is_file())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
43
tests/integration/test_yaml_syntax.py
Normal file
43
tests/integration/test_yaml_syntax.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
SKIP_DIR_NAMES = {".git", ".ruff_cache", "__pycache__", "node_modules"}
|
||||||
|
SKIP_FILES = {"app/config.yaml"}
|
||||||
|
YAML_SUFFIXES = {".yml", ".yaml"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestYamlSyntax(unittest.TestCase):
|
||||||
|
def test_all_repository_yaml_files_are_valid(self):
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
invalid_files = []
|
||||||
|
|
||||||
|
for path in repo_root.rglob("*"):
|
||||||
|
if not path.is_file() or path.suffix not in YAML_SUFFIXES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
relative_path = path.relative_to(repo_root).as_posix()
|
||||||
|
if relative_path in SKIP_FILES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if any(part in SKIP_DIR_NAMES for part in path.parts):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
yaml.safe_load(handle)
|
||||||
|
except yaml.YAMLError as error:
|
||||||
|
invalid_files.append((relative_path, str(error).splitlines()[0]))
|
||||||
|
except Exception as error:
|
||||||
|
invalid_files.append((relative_path, f"Unexpected error: {error}"))
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
invalid_files,
|
||||||
|
"Found invalid YAML files:\n"
|
||||||
|
+ "\n".join(f"- {path}: {error}" for path, error in invalid_files),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
1
tests/lint/__init__.py
Normal file
1
tests/lint/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
90
tests/lint/test_all_test_files_have_tests.py
Normal file
90
tests/lint/test_all_test_files_have_tests.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import ast
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestTestFilesContainUnittestTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
self.tests_dir = self.repo_root / "tests"
|
||||||
|
self.assertTrue(
|
||||||
|
self.tests_dir.is_dir(),
|
||||||
|
f"'tests' directory not found at: {self.tests_dir}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _iter_test_files(self) -> list[Path]:
|
||||||
|
return sorted(self.tests_dir.rglob("test_*.py"))
|
||||||
|
|
||||||
|
def _file_contains_runnable_unittest_test(self, path: Path) -> bool:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source, filename=str(path))
|
||||||
|
except SyntaxError as error:
|
||||||
|
raise AssertionError(f"SyntaxError in {path}: {error}") from error
|
||||||
|
|
||||||
|
testcase_aliases = {"TestCase"}
|
||||||
|
unittest_aliases = {"unittest"}
|
||||||
|
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for import_name in node.names:
|
||||||
|
if import_name.name == "unittest":
|
||||||
|
unittest_aliases.add(import_name.asname or "unittest")
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module == "unittest":
|
||||||
|
for import_name in node.names:
|
||||||
|
if import_name.name == "TestCase":
|
||||||
|
testcase_aliases.add(import_name.asname or "TestCase")
|
||||||
|
|
||||||
|
def is_testcase_base(base: ast.expr) -> bool:
|
||||||
|
if isinstance(base, ast.Name) and base.id in testcase_aliases:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(base, ast.Attribute) and base.attr == "TestCase":
|
||||||
|
return (
|
||||||
|
isinstance(base.value, ast.Name)
|
||||||
|
and base.value.id in unittest_aliases
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
|
||||||
|
node.name.startswith("test_")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
for node in tree.body:
|
||||||
|
if not isinstance(node, ast.ClassDef):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not any(is_testcase_base(base) for base in node.bases):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in node.body:
|
||||||
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
|
||||||
|
item.name.startswith("test_")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_all_test_py_files_contain_runnable_tests(self) -> None:
|
||||||
|
test_files = self._iter_test_files()
|
||||||
|
self.assertTrue(test_files, "No test_*.py files found under tests/")
|
||||||
|
|
||||||
|
offenders = []
|
||||||
|
for path in test_files:
|
||||||
|
if not self._file_contains_runnable_unittest_test(path):
|
||||||
|
offenders.append(path.relative_to(self.repo_root).as_posix())
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
offenders,
|
||||||
|
"These test_*.py files do not define any unittest-runnable tests:\n"
|
||||||
|
+ "\n".join(f"- {path}" for path in offenders),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
25
tests/lint/test_test_file_naming.py
Normal file
25
tests/lint/test_test_file_naming.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestTestFileNaming(unittest.TestCase):
|
||||||
|
def test_all_python_files_use_test_prefix(self):
|
||||||
|
tests_root = Path(__file__).resolve().parents[1]
|
||||||
|
invalid_files = []
|
||||||
|
|
||||||
|
for path in tests_root.rglob("*.py"):
|
||||||
|
if path.name == "__init__.py":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not path.name.startswith("test_"):
|
||||||
|
invalid_files.append(path.relative_to(tests_root).as_posix())
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
invalid_files,
|
||||||
|
"The following Python files do not start with 'test_':\n"
|
||||||
|
+ "\n".join(f"- {path}" for path in invalid_files),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
1
tests/security/__init__.py
Normal file
1
tests/security/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
57
tests/security/test_config_hygiene.py
Normal file
57
tests/security/test_config_hygiene.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigHygiene(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
self.sample_config_path = self.repo_root / "app" / "config.sample.yaml"
|
||||||
|
|
||||||
|
def _is_tracked(self, path: str) -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-files", "--error-unmatch", path],
|
||||||
|
cwd=self.repo_root,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
def _find_values_for_key(self, data, key_name: str):
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key, value in data.items():
|
||||||
|
if key == key_name:
|
||||||
|
yield value
|
||||||
|
yield from self._find_values_for_key(value, key_name)
|
||||||
|
elif isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
yield from self._find_values_for_key(item, key_name)
|
||||||
|
|
||||||
|
def test_runtime_only_files_are_ignored_and_untracked(self):
|
||||||
|
gitignore_lines = (
|
||||||
|
(self.repo_root / ".gitignore").read_text(encoding="utf-8").splitlines()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("app/config.yaml", gitignore_lines)
|
||||||
|
self.assertIn(".env", gitignore_lines)
|
||||||
|
self.assertFalse(self._is_tracked("app/config.yaml"))
|
||||||
|
self.assertFalse(self._is_tracked(".env"))
|
||||||
|
|
||||||
|
def test_sample_config_keeps_the_nasa_api_key_placeholder(self):
|
||||||
|
with self.sample_config_path.open("r", encoding="utf-8") as handle:
|
||||||
|
sample_config = yaml.safe_load(handle)
|
||||||
|
|
||||||
|
nasa_api_keys = list(self._find_values_for_key(sample_config, "nasa_api_key"))
|
||||||
|
self.assertEqual(
|
||||||
|
nasa_api_keys,
|
||||||
|
["YOUR_REAL_KEY_HERE"],
|
||||||
|
"config.sample.yaml should only contain the documented NASA API key "
|
||||||
|
"placeholder.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
43
tests/security/test_sample_config_urls.py
Normal file
43
tests/security/test_sample_config_urls.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ALLOWED_URL_PREFIXES = ("https://", "mailto:", "tel:")
|
||||||
|
URL_KEYS = {"url", "imprint", "imprint_url"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSampleConfigUrls(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
sample_config_path = repo_root / "app" / "config.sample.yaml"
|
||||||
|
with sample_config_path.open("r", encoding="utf-8") as handle:
|
||||||
|
self.sample_config = yaml.safe_load(handle)
|
||||||
|
|
||||||
|
def _iter_urls(self, data, path="root"):
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key, value in data.items():
|
||||||
|
next_path = f"{path}.{key}"
|
||||||
|
if key in URL_KEYS and isinstance(value, str):
|
||||||
|
yield next_path, value
|
||||||
|
yield from self._iter_urls(value, next_path)
|
||||||
|
elif isinstance(data, list):
|
||||||
|
for index, item in enumerate(data):
|
||||||
|
yield from self._iter_urls(item, f"{path}[{index}]")
|
||||||
|
|
||||||
|
def test_sample_config_urls_use_safe_schemes(self):
|
||||||
|
invalid_urls = [
|
||||||
|
f"{path} -> {url}"
|
||||||
|
for path, url in self._iter_urls(self.sample_config)
|
||||||
|
if not url.startswith(ALLOWED_URL_PREFIXES)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
invalid_urls,
|
||||||
|
"The sample config contains URLs with unsupported schemes:\n"
|
||||||
|
+ "\n".join(f"- {entry}" for entry in invalid_urls),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Unit test package for Portfolio UI."""
|
||||||
72
tests/unit/test_cache_manager.py
Normal file
72
tests/unit/test_cache_manager.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.utils.cache_manager import CacheManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestCacheManager(unittest.TestCase):
|
||||||
|
def test_init_creates_cache_directory(self):
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
cache_dir = Path(temp_dir) / "cache"
|
||||||
|
|
||||||
|
self.assertFalse(cache_dir.exists())
|
||||||
|
|
||||||
|
CacheManager(str(cache_dir))
|
||||||
|
|
||||||
|
self.assertTrue(cache_dir.is_dir())
|
||||||
|
|
||||||
|
def test_clear_cache_removes_files_but_keeps_subdirectories(self):
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
cache_dir = Path(temp_dir) / "cache"
|
||||||
|
nested_dir = cache_dir / "nested"
|
||||||
|
nested_dir.mkdir(parents=True)
|
||||||
|
file_path = cache_dir / "icon.png"
|
||||||
|
file_path.write_bytes(b"icon")
|
||||||
|
|
||||||
|
manager = CacheManager(str(cache_dir))
|
||||||
|
manager.clear_cache()
|
||||||
|
|
||||||
|
self.assertFalse(file_path.exists())
|
||||||
|
self.assertTrue(nested_dir.is_dir())
|
||||||
|
|
||||||
|
@patch("app.utils.cache_manager.requests.get")
|
||||||
|
def test_cache_file_downloads_and_stores_response(self, mock_get):
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
manager = CacheManager(str(Path(temp_dir) / "cache"))
|
||||||
|
response = Mock()
|
||||||
|
response.headers = {"Content-Type": "image/svg+xml; charset=utf-8"}
|
||||||
|
response.iter_content.return_value = [b"<svg>ok</svg>"]
|
||||||
|
response.raise_for_status.return_value = None
|
||||||
|
mock_get.return_value = response
|
||||||
|
|
||||||
|
cached_path = manager.cache_file("https://example.com/logo/download")
|
||||||
|
|
||||||
|
self.assertIsNotNone(cached_path)
|
||||||
|
self.assertTrue(cached_path.startswith("cache/logo_"))
|
||||||
|
self.assertTrue(cached_path.endswith(".svg"))
|
||||||
|
|
||||||
|
stored_file = Path(manager.cache_dir) / Path(cached_path).name
|
||||||
|
self.assertEqual(stored_file.read_bytes(), b"<svg>ok</svg>")
|
||||||
|
mock_get.assert_called_once_with(
|
||||||
|
"https://example.com/logo/download",
|
||||||
|
stream=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("app.utils.cache_manager.requests.get")
|
||||||
|
def test_cache_file_returns_none_when_request_fails(self, mock_get):
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
manager = CacheManager(str(Path(temp_dir) / "cache"))
|
||||||
|
mock_get.side_effect = requests.RequestException("network")
|
||||||
|
|
||||||
|
cached_path = manager.cache_file("https://example.com/icon.png")
|
||||||
|
|
||||||
|
self.assertIsNone(cached_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
49
tests/unit/test_check_hadolint_sarif.py
Normal file
49
tests/unit/test_check_hadolint_sarif.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from utils import check_hadolint_sarif
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckHadolintSarif(unittest.TestCase):
|
||||||
|
def test_main_returns_zero_for_clean_sarif(self):
|
||||||
|
sarif_payload = {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
sarif_path = Path(temp_dir) / "clean.sarif"
|
||||||
|
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
|
||||||
|
|
||||||
|
exit_code = check_hadolint_sarif.main([str(sarif_path)])
|
||||||
|
|
||||||
|
self.assertEqual(exit_code, 0)
|
||||||
|
|
||||||
|
def test_main_returns_one_for_warnings_or_errors(self):
|
||||||
|
sarif_payload = {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{"level": "warning"},
|
||||||
|
{"level": "error"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
sarif_path = Path(temp_dir) / "warnings.sarif"
|
||||||
|
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
|
||||||
|
|
||||||
|
exit_code = check_hadolint_sarif.main([str(sarif_path)])
|
||||||
|
|
||||||
|
self.assertEqual(exit_code, 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
39
tests/unit/test_compute_card_classes.py
Normal file
39
tests/unit/test_compute_card_classes.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.utils.compute_card_classes import compute_card_classes
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputeCardClasses(unittest.TestCase):
|
||||||
|
def test_single_card_uses_full_width_classes(self):
|
||||||
|
lg_classes, md_classes = compute_card_classes([{"title": "One"}])
|
||||||
|
|
||||||
|
self.assertEqual(lg_classes, ["col-lg-12"])
|
||||||
|
self.assertEqual(md_classes, ["col-md-12"])
|
||||||
|
|
||||||
|
def test_two_cards_split_evenly(self):
|
||||||
|
lg_classes, md_classes = compute_card_classes([{}, {}])
|
||||||
|
|
||||||
|
self.assertEqual(lg_classes, ["col-lg-6", "col-lg-6"])
|
||||||
|
self.assertEqual(md_classes, ["col-md-6", "col-md-6"])
|
||||||
|
|
||||||
|
def test_three_cards_use_thirds(self):
|
||||||
|
lg_classes, md_classes = compute_card_classes([{}, {}, {}])
|
||||||
|
|
||||||
|
self.assertEqual(lg_classes, ["col-lg-4", "col-lg-4", "col-lg-4"])
|
||||||
|
self.assertEqual(md_classes, ["col-md-6", "col-md-6", "col-md-12"])
|
||||||
|
|
||||||
|
def test_five_cards_use_balanced_large_layout(self):
|
||||||
|
lg_classes, md_classes = compute_card_classes([{}, {}, {}, {}, {}])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
lg_classes,
|
||||||
|
["col-lg-6", "col-lg-6", "col-lg-4", "col-lg-4", "col-lg-4"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
md_classes,
|
||||||
|
["col-md-6", "col-md-6", "col-md-6", "col-md-6", "col-md-12"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
74
tests/unit/test_configuration_resolver.py
Normal file
74
tests/unit/test_configuration_resolver.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.utils.configuration_resolver import ConfigurationResolver
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigurationResolver(unittest.TestCase):
|
||||||
|
def test_resolve_links_replaces_mapping_link_with_target_object(self):
|
||||||
|
config = {
|
||||||
|
"profiles": [
|
||||||
|
{"name": "Mastodon", "url": "https://example.com/@user"},
|
||||||
|
],
|
||||||
|
"featured": {"link": "profiles.mastodon"},
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver = ConfigurationResolver(config)
|
||||||
|
resolver.resolve_links()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
resolver.get_config()["featured"],
|
||||||
|
{"name": "Mastodon", "url": "https://example.com/@user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolve_links_expands_children_link_to_list_entries(self):
|
||||||
|
config = {
|
||||||
|
"accounts": {
|
||||||
|
"children": [
|
||||||
|
{"name": "Matrix", "url": "https://matrix.example"},
|
||||||
|
{"name": "Signal", "url": "https://signal.example"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"children": [
|
||||||
|
{"link": "accounts.children"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver = ConfigurationResolver(config)
|
||||||
|
resolver.resolve_links()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
resolver.get_config()["navigation"]["children"],
|
||||||
|
[
|
||||||
|
{"name": "Matrix", "url": "https://matrix.example"},
|
||||||
|
{"name": "Signal", "url": "https://signal.example"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolve_links_rejects_non_list_children(self):
|
||||||
|
config = {"navigation": {"children": {"name": "Invalid"}}}
|
||||||
|
|
||||||
|
resolver = ConfigurationResolver(config)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
resolver.resolve_links()
|
||||||
|
|
||||||
|
def test_find_entry_handles_case_and_space_insensitive_paths(self):
|
||||||
|
config = {
|
||||||
|
"Social Networks": {
|
||||||
|
"children": [
|
||||||
|
{"name": "Friendica", "url": "https://friendica.example"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver = ConfigurationResolver(config)
|
||||||
|
|
||||||
|
entry = resolver._find_entry(config, "socialnetworks.friendica", False)
|
||||||
|
|
||||||
|
self.assertEqual(entry["url"], "https://friendica.example")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
45
tests/unit/test_export_runtime_requirements.py
Normal file
45
tests/unit/test_export_runtime_requirements.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from utils import export_runtime_requirements
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportRuntimeRequirements(unittest.TestCase):
|
||||||
|
def test_load_runtime_requirements_reads_project_dependencies(self):
|
||||||
|
pyproject_content = """
|
||||||
|
[project]
|
||||||
|
dependencies = [
|
||||||
|
"flask",
|
||||||
|
"requests>=2",
|
||||||
|
]
|
||||||
|
""".lstrip()
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
pyproject_path = Path(temp_dir) / "pyproject.toml"
|
||||||
|
pyproject_path.write_text(pyproject_content, encoding="utf-8")
|
||||||
|
|
||||||
|
requirements = export_runtime_requirements.load_runtime_requirements(
|
||||||
|
pyproject_path
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(requirements, ["flask", "requests>=2"])
|
||||||
|
|
||||||
|
def test_main_prints_requirements_from_selected_pyproject(self):
|
||||||
|
pyproject_content = """
|
||||||
|
[project]
|
||||||
|
dependencies = [
|
||||||
|
"pyyaml",
|
||||||
|
]
|
||||||
|
""".lstrip()
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
pyproject_path = Path(temp_dir) / "pyproject.toml"
|
||||||
|
pyproject_path.write_text(pyproject_content, encoding="utf-8")
|
||||||
|
|
||||||
|
with patch("builtins.print") as mock_print:
|
||||||
|
exit_code = export_runtime_requirements.main([str(pyproject_path)])
|
||||||
|
|
||||||
|
self.assertEqual(exit_code, 0)
|
||||||
|
mock_print.assert_called_once_with("pyyaml")
|
||||||
72
tests/unit/test_main.py
Normal file
72
tests/unit/test_main.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import main as portfolio_main
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainCli(unittest.TestCase):
|
||||||
|
def test_load_targets_parses_help_comments(self):
|
||||||
|
makefile_content = """
|
||||||
|
.PHONY: foo bar
|
||||||
|
foo:
|
||||||
|
\t# Run foo
|
||||||
|
\t@echo foo
|
||||||
|
|
||||||
|
bar:
|
||||||
|
\t@echo bar
|
||||||
|
""".lstrip()
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
makefile_path = Path(temp_dir) / "Makefile"
|
||||||
|
makefile_path.write_text(makefile_content, encoding="utf-8")
|
||||||
|
|
||||||
|
targets = portfolio_main.load_targets(makefile_path)
|
||||||
|
|
||||||
|
self.assertEqual(targets, [("foo", "Run foo"), ("bar", "")])
|
||||||
|
|
||||||
|
@patch("main.subprocess.check_call")
|
||||||
|
def test_run_command_executes_subprocess(self, mock_check_call):
|
||||||
|
portfolio_main.run_command(["make", "lint"])
|
||||||
|
|
||||||
|
mock_check_call.assert_called_once_with(["make", "lint"])
|
||||||
|
|
||||||
|
@patch("main.sys.exit", side_effect=SystemExit(7))
|
||||||
|
@patch(
|
||||||
|
"main.subprocess.check_call",
|
||||||
|
side_effect=subprocess.CalledProcessError(7, ["make", "lint"]),
|
||||||
|
)
|
||||||
|
def test_run_command_exits_with_subprocess_return_code(
|
||||||
|
self,
|
||||||
|
_mock_check_call,
|
||||||
|
mock_sys_exit,
|
||||||
|
):
|
||||||
|
with self.assertRaises(SystemExit) as context:
|
||||||
|
portfolio_main.run_command(["make", "lint"])
|
||||||
|
|
||||||
|
self.assertEqual(context.exception.code, 7)
|
||||||
|
mock_sys_exit.assert_called_once_with(7)
|
||||||
|
|
||||||
|
@patch("main.run_command")
|
||||||
|
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
|
||||||
|
def test_main_dispatches_selected_target(
|
||||||
|
self, _mock_load_targets, mock_run_command
|
||||||
|
):
|
||||||
|
with patch("sys.argv", ["main.py", "lint"]):
|
||||||
|
portfolio_main.main()
|
||||||
|
|
||||||
|
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=False)
|
||||||
|
|
||||||
|
@patch("main.run_command")
|
||||||
|
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
|
||||||
|
def test_main_passes_dry_run_flag(self, _mock_load_targets, mock_run_command):
|
||||||
|
with patch("sys.argv", ["main.py", "--dry-run", "lint"]):
|
||||||
|
portfolio_main.main()
|
||||||
|
|
||||||
|
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
70
tests/unit/test_navigation_template.py
Normal file
70
tests/unit/test_navigation_template.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import unittest
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
|
||||||
|
class AnchorCollector(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.anchors = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag == "a":
|
||||||
|
self.anchors.append(dict(attrs))
|
||||||
|
|
||||||
|
|
||||||
|
class TestNavigationTemplate(unittest.TestCase):
|
||||||
|
def test_top_level_dropdowns_have_bootstrap_toggle_attribute(self):
|
||||||
|
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
|
||||||
|
environment = Environment(
|
||||||
|
loader=FileSystemLoader(template_dir),
|
||||||
|
autoescape=select_autoescape(),
|
||||||
|
)
|
||||||
|
environment.globals["url_for"] = lambda _endpoint, filename: (
|
||||||
|
f"/static/{filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = environment.get_template("moduls/navigation.html.j2").render(
|
||||||
|
menu_type="header",
|
||||||
|
platform={
|
||||||
|
"titel": "Portfolio",
|
||||||
|
"logo": {"cache": "logo.png"},
|
||||||
|
},
|
||||||
|
navigation={
|
||||||
|
"header": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "Apps",
|
||||||
|
"description": "Application menu",
|
||||||
|
"icon": {"class": "fa-solid fa-grid"},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "Example",
|
||||||
|
"description": "Example app",
|
||||||
|
"icon": {"class": "fa-solid fa-link"},
|
||||||
|
"url": "https://example.test",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = AnchorCollector()
|
||||||
|
parser.feed(rendered)
|
||||||
|
dropdown_toggles = [
|
||||||
|
anchor
|
||||||
|
for anchor in parser.anchors
|
||||||
|
if "nav-link" in anchor.get("class", "")
|
||||||
|
and "dropdown-toggle" in anchor.get("class", "")
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(len(dropdown_toggles), 1)
|
||||||
|
self.assertEqual(dropdown_toggles[0].get("data-bs-toggle"), "dropdown")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
28
utils/check_hadolint_sarif.py
Normal file
28
utils/check_hadolint_sarif.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail when a hadolint SARIF report contains warnings or errors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = argv if argv is not None else sys.argv[1:]
|
||||||
|
sarif_path = Path(args[0] if args else "hadolint-results.sarif")
|
||||||
|
|
||||||
|
with sarif_path.open("r", encoding="utf-8") as handle:
|
||||||
|
sarif = json.load(handle)
|
||||||
|
|
||||||
|
results = sarif.get("runs", [{}])[0].get("results", [])
|
||||||
|
levels = [result.get("level", "") for result in results]
|
||||||
|
warnings = sum(1 for level in levels if level == "warning")
|
||||||
|
errors = sum(1 for level in levels if level == "error")
|
||||||
|
|
||||||
|
print(f"SARIF results: total={len(results)} warnings={warnings} errors={errors}")
|
||||||
|
return 1 if warnings + errors > 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
30
utils/export_runtime_requirements.py
Normal file
30
utils/export_runtime_requirements.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Print runtime dependencies from pyproject.toml, one per line."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_PYPROJECT_PATH = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def load_runtime_requirements(
|
||||||
|
pyproject_path: Path = DEFAULT_PYPROJECT_PATH,
|
||||||
|
) -> list[str]:
|
||||||
|
with pyproject_path.open("rb") as handle:
|
||||||
|
pyproject = tomllib.load(handle)
|
||||||
|
return list(pyproject["project"]["dependencies"])
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = argv if argv is not None else sys.argv[1:]
|
||||||
|
pyproject_path = Path(args[0]) if args else DEFAULT_PYPROJECT_PATH
|
||||||
|
|
||||||
|
for requirement in load_runtime_requirements(pyproject_path):
|
||||||
|
print(requirement)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user