mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-04-07 21:32:20 +00:00
Compare commits
94 Commits
dc11dc799b
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e03e740149 | |||
| c96702035f |
89
.claude/settings.json
Normal file
89
.claude/settings.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Bash(git status*)",
|
||||
"Bash(git log*)",
|
||||
"Bash(git diff*)",
|
||||
"Bash(git add*)",
|
||||
"Bash(git commit*)",
|
||||
"Bash(git checkout*)",
|
||||
"Bash(git branch*)",
|
||||
"Bash(git fetch*)",
|
||||
"Bash(git stash*)",
|
||||
"Bash(git -C:*)",
|
||||
"Bash(make*)",
|
||||
"Bash(python3*)",
|
||||
"Bash(python*)",
|
||||
"Bash(pip show*)",
|
||||
"Bash(pip list*)",
|
||||
"Bash(pip install*)",
|
||||
"Bash(npm install*)",
|
||||
"Bash(npm run*)",
|
||||
"Bash(npx*)",
|
||||
"Bash(docker pull*)",
|
||||
"Bash(docker build*)",
|
||||
"Bash(docker images*)",
|
||||
"Bash(docker ps*)",
|
||||
"Bash(docker inspect*)",
|
||||
"Bash(docker logs*)",
|
||||
"Bash(docker create*)",
|
||||
"Bash(docker export*)",
|
||||
"Bash(docker rm*)",
|
||||
"Bash(docker rmi*)",
|
||||
"Bash(docker stop*)",
|
||||
"Bash(docker compose*)",
|
||||
"Bash(docker-compose*)",
|
||||
"Bash(docker container prune*)",
|
||||
"Bash(grep*)",
|
||||
"Bash(find*)",
|
||||
"Bash(ls*)",
|
||||
"Bash(cat*)",
|
||||
"Bash(head*)",
|
||||
"Bash(tail*)",
|
||||
"Bash(wc*)",
|
||||
"Bash(sort*)",
|
||||
"Bash(tar*)",
|
||||
"Bash(mkdir*)",
|
||||
"Bash(cp*)",
|
||||
"Bash(mv*)",
|
||||
"Bash(jq*)",
|
||||
"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)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push*)",
|
||||
"Bash(docker run*)",
|
||||
"Bash(curl*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(rm -rf*)",
|
||||
"Bash(sudo*)"
|
||||
]
|
||||
},
|
||||
"sandbox": {
|
||||
"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
|
||||
*__pycache__*
|
||||
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.
|
||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## [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:slim
|
||||
FROM python:3.12-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
|
||||
|
||||
# Abhängigkeiten kopieren und installieren
|
||||
COPY app/requirements.txt requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Anwendungscode kopieren
|
||||
COPY app/ .
|
||||
RUN npm install --prefix /app
|
||||
|
||||
# Port freigeben
|
||||
EXPOSE 5000
|
||||
|
||||
# Startbefehl
|
||||
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
|
||||
|
||||
174
Makefile
Normal file
174
Makefile
Normal file
@@ -0,0 +1,174 @@
|
||||
# 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: npm-install
|
||||
npm-install:
|
||||
# Install Node.js dependencies for browser tests.
|
||||
cd app && npm install
|
||||
|
||||
.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: npm-install
|
||||
# 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.
|
||||
198
README.md
198
README.md
@@ -1,50 +1,95 @@
|
||||
# Portfolio: Flask-based Portfolio 🚀
|
||||
# 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.
|
||||
- **Customizable Cards**: Showcase your skills, projects, or services.
|
||||
- **Cache Management**: Optimize your assets with automatic caching.
|
||||
- **Responsive Design**: Beautiful on any device with Bootstrap.
|
||||
- **Easy Configuration**: Update content using a YAML file.
|
||||
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
|
||||
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
|
||||
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevin’s personal site)
|
||||
|
||||
## Access 🌐
|
||||
---
|
||||
|
||||
### Locale
|
||||
Access the application locally at [http://127.0.0.1:5000](http://127.0.0.1:5000).
|
||||
## ✨ Key Features
|
||||
|
||||
## 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.
|
||||
- Basic knowledge of Python and YAML for configuration.
|
||||
## 🌐 Quick Access
|
||||
|
||||
### 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
|
||||
git clone <repository_url>
|
||||
cd <repository_directory>
|
||||
```
|
||||
|
||||
2. **Update the configuration:**
|
||||
Create a `config.yaml` file. You can use `config.sample.yaml` as an example (see below for details on the configuration).
|
||||
2. **Configure**
|
||||
Copy `config.sample.yaml` → `config.yaml` & customize.
|
||||
3. **Build & run**
|
||||
|
||||
3. **Build and run the Docker container:**
|
||||
```bash
|
||||
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
|
||||
accounts:
|
||||
@@ -57,14 +102,9 @@ accounts:
|
||||
description: Platforms where I share content.
|
||||
icon:
|
||||
class: fas fa-newspaper
|
||||
children:
|
||||
- name: Microblogs
|
||||
description: Stay updated with my microblog posts.
|
||||
icon:
|
||||
class: fa-solid fa-pen-nib
|
||||
children:
|
||||
- name: Mastodon
|
||||
description: Follow my updates on Mastodon.
|
||||
description: Follow me on Mastodon.
|
||||
icon:
|
||||
class: fa-brands fa-mastodon
|
||||
url: https://microblog.veen.world/@kevinveenbirkenbach
|
||||
@@ -76,9 +116,10 @@ accounts:
|
||||
text: I lead agile transformations and improve team dynamics through Scrum and Agile Coaching.
|
||||
url: https://www.agile-coach.world
|
||||
link_text: www.agile-coach.world
|
||||
|
||||
company:
|
||||
titel: Kevin Veen-Birkenbach
|
||||
subtitel: Consulting and Coaching Solutions
|
||||
title: Kevin Veen-Birkenbach
|
||||
subtitle: Consulting & Coaching Solutions
|
||||
logo:
|
||||
source: https://cloud.veen.world/s/logo_face_512x512/download
|
||||
favicon:
|
||||
@@ -91,92 +132,27 @@ company:
|
||||
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
|
||||
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
|
||||
```
|
||||
## 🚢 Production Deployment
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
Licensed under **GNU AGPLv3**. See [LICENSE](./LICENSE) for details.
|
||||
|
||||
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
|
||||
```bash
|
||||
docker stop portfolio; docker rm portfolio
|
||||
```
|
||||
Created by [Kevin Veen-Birkenbach](https://www.veen.world/)
|
||||
|
||||
### Build
|
||||
```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.
|
||||
Enjoy building your portfolio! 🌟
|
||||
|
||||
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
|
||||
from flask import Flask, render_template
|
||||
|
||||
import requests
|
||||
import hashlib
|
||||
import yaml
|
||||
from utils.configuration_resolver import ConfigurationResolver
|
||||
from utils.cache_manager import CacheManager
|
||||
from flask import Flask, current_app, render_template
|
||||
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
|
||||
cache_manager = CacheManager()
|
||||
@@ -12,39 +28,106 @@ cache_manager = CacheManager()
|
||||
# Clear cache on startup
|
||||
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.resolve_links()
|
||||
# Update the app configuration
|
||||
app.config.update(resolver.get_config())
|
||||
|
||||
app = Flask(__name__)
|
||||
load_config(app)
|
||||
|
||||
# Hole die Umgebungsvariable FLASK_ENV oder setze einen Standardwert
|
||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
||||
def cache_icons_and_logos(app):
|
||||
"""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
|
||||
def reload_config_in_dev():
|
||||
"""Reload config and recache icons before each request in development mode."""
|
||||
if FLASK_ENV == "development":
|
||||
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.config["company"]["favicon"]["cache"] = cache_manager.cache_file(app.config["company"]["favicon"]["source"])
|
||||
|
||||
@app.route('/')
|
||||
@app.route("/")
|
||||
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__":
|
||||
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:
|
||||
name: Online Accounts
|
||||
nasa_api_key: YOUR_REAL_KEY_HERE
|
||||
name: Online Presence
|
||||
description: Discover my online presence.
|
||||
icon:
|
||||
class: fa-solid fa-users
|
||||
children:
|
||||
- name: Channels
|
||||
- name: Publishing Channels
|
||||
description: Platforms where I share content.
|
||||
icon:
|
||||
class: fas fa-newspaper
|
||||
@@ -29,14 +30,14 @@ accounts:
|
||||
identifier: kevinbirkenbach
|
||||
warning: I rarely use X/Twitter and recommend alternative platforms like Mastodon.
|
||||
alternatives:
|
||||
- link: accounts.channels.microblogs.mastodon
|
||||
- link: accounts.publishingchannels.microblogs.mastodon
|
||||
- name: Bluesky
|
||||
description: Follow me on Bluesky (coming soon).
|
||||
icon:
|
||||
class: fa-brands fa-bluesky
|
||||
info: Bluesky is coming soon.
|
||||
alternatives:
|
||||
- link: accounts.channels.microblogs.mastodon
|
||||
- link: accounts.publishingchannels.microblogs.mastodon
|
||||
|
||||
- name: Pictures
|
||||
description: View my photography.
|
||||
@@ -56,7 +57,7 @@ accounts:
|
||||
identifier: kevinveenbirkenbach
|
||||
warning: Platforms by Meta (e.g., Instagram, Facebook) may compromise your data privacy. Consider using decentralized alternatives.
|
||||
alternatives:
|
||||
- link: accounts.channels.pictures.pixelfed
|
||||
- link: accounts.publishingchannels.pictures.pixelfed
|
||||
|
||||
- name: Videos
|
||||
description: Watch my video content.
|
||||
@@ -75,7 +76,7 @@ accounts:
|
||||
url: https://s.veen.world/youtube
|
||||
warning: I no longer publish videos on YouTube. Please visit my Peertube channel instead.
|
||||
alternatives:
|
||||
- link: accounts.channels.videos.peertube
|
||||
- link: accounts.publishingchannels.videos.peertube
|
||||
|
||||
- name: Blog
|
||||
description: Read my articles and stories.
|
||||
@@ -99,17 +100,25 @@ accounts:
|
||||
class: fa-solid fa-code
|
||||
url: https://git.veen.world/kevinveenbirkenbach
|
||||
|
||||
- name: Social Media
|
||||
- name: Social Networks
|
||||
description: Social and developer platforms.
|
||||
icon:
|
||||
class: fa-brands fa-meta
|
||||
class: fa fa-users
|
||||
children:
|
||||
- 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.
|
||||
icon:
|
||||
class: fa-brands fa-facebook
|
||||
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
|
||||
|
||||
- name: Career Profiles
|
||||
@@ -121,13 +130,27 @@ accounts:
|
||||
description: View my XING profile.
|
||||
icon:
|
||||
class: bi bi-building
|
||||
url: https://www.xing.com/profile/Kevin_VeenBirkenbach
|
||||
url: https://s.veen.world/xing
|
||||
- name: LinkedIn
|
||||
description: Connect with me on LinkedIn.
|
||||
icon:
|
||||
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
|
||||
description: My sports activities and logs.
|
||||
icon:
|
||||
@@ -168,6 +191,12 @@ accounts:
|
||||
class: fa-brands fa-discourse
|
||||
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:
|
||||
- icon:
|
||||
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.
|
||||
url: https://www.agile-coach.world
|
||||
link_text: www.agile-coach.world
|
||||
iframe: true
|
||||
- icon:
|
||||
source: https://cloud.veen.world/s/logo_personal_coach_512x512/download
|
||||
title: Personal Coach
|
||||
@@ -236,59 +266,66 @@ cards:
|
||||
and embrace positive life changes.
|
||||
url: https://www.hypno.veen.world
|
||||
link_text: www.hypno.veen.world
|
||||
- icon:
|
||||
source: https://cloud.veen.world/s/logo_skydiver_512x512/download
|
||||
title: Aerospace Consultant
|
||||
text: As an Aerospace Consultant with aviation credentials, including a Sport Pilot
|
||||
License for Parachutes, and a Restricted Radiotelephony and Operator's Certificate
|
||||
I deliver expert consulting services. Currently training for my Private Pilot
|
||||
License, I specialize in guiding clients through aviation regulations, safety
|
||||
standards, and operational efficiency.
|
||||
url:
|
||||
link_text: Website under construction
|
||||
- icon:
|
||||
source: https://cloud.veen.world/s/logo_hunter_512x512/download
|
||||
title: Wildlife Expert
|
||||
text: As a certified hunter and wildlife coach, I offer educational programs, nature
|
||||
walks, survival trainings, and photo expeditions, merging ecological knowledge
|
||||
with nature respect. My goal is to foster sustainable conservation and enhance
|
||||
appreciation for the natural world through responsible practices.
|
||||
url:
|
||||
link_text: Website under construction
|
||||
- icon:
|
||||
source: https://cloud.veen.world/s/logo_diver_512x512/download
|
||||
title: Master Diver
|
||||
text: As a certified master diver with trainings in various specialties, I offer
|
||||
diving instruction, underwater photography, and guided dive tours. My experience
|
||||
ensures safe and enriching underwater adventures, highlighting marine conservation
|
||||
and the wonders of aquatic ecosystems.
|
||||
url:
|
||||
link_text: Website under construction
|
||||
- icon:
|
||||
source: https://cloud.veen.world/s/logo_massage_therapist_512x512/download
|
||||
title: Massage Therapist
|
||||
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
|
||||
relaxation, focus on energy flow, personal growth, and spiritual awakening.
|
||||
url:
|
||||
link_text: Website under construction
|
||||
company:
|
||||
#- icon:
|
||||
# source: https://cloud.veen.world/s/logo_skydiver_512x512/download
|
||||
# title: Aerospace Consultant
|
||||
# text: As an Aerospace Consultant with aviation credentials, including a Sport Pilot
|
||||
# License for Parachutes, and a Restricted Radiotelephony and Operator's Certificate
|
||||
# I deliver expert consulting services. Currently training for my Private Pilot
|
||||
# License, I specialize in guiding clients through aviation regulations, safety
|
||||
# standards, and operational efficiency.
|
||||
# url:
|
||||
# link_text: Website under construction
|
||||
#- icon:
|
||||
# source: https://cloud.veen.world/s/logo_hunter_512x512/download
|
||||
# title: Wildlife Expert
|
||||
# text: As a certified hunter and wildlife coach, I offer educational programs, nature
|
||||
# walks, survival trainings, and photo expeditions, merging ecological knowledge
|
||||
# with nature respect. My goal is to foster sustainable conservation and enhance
|
||||
# appreciation for the natural world through responsible practices.
|
||||
# url:
|
||||
# link_text: Website under construction
|
||||
#- icon:
|
||||
# source: https://cloud.veen.world/s/logo_diver_512x512/download
|
||||
# title: Master Diver
|
||||
# text: As a certified master diver with trainings in various specialties, I offer
|
||||
# diving instruction, underwater photography, and guided dive tours. My experience
|
||||
# ensures safe and enriching underwater adventures, highlighting marine conservation
|
||||
# and the wonders of aquatic ecosystems.
|
||||
# url:
|
||||
# link_text: Website under construction
|
||||
#- icon:
|
||||
# source: https://cloud.veen.world/s/logo_massage_therapist_512x512/download
|
||||
# title: Massage Therapist
|
||||
# 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
|
||||
# relaxation, focus on energy flow, personal growth, and spiritual awakening.
|
||||
# url:
|
||||
# link_text: Website under construction
|
||||
platform:
|
||||
titel: Kevin Veen-Birkenbach
|
||||
subtitel: Consulting and Coaching Solutions
|
||||
logo:
|
||||
source: https://cloud.veen.world/s/logo_face_512x512/download
|
||||
favicon:
|
||||
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:
|
||||
street: Afrikanische Straße 43
|
||||
postal_code: DE-13351
|
||||
city: Berlin
|
||||
country: Germany
|
||||
imprint_url: https://s.veen.world/imprint
|
||||
imprint: https://veen.world/
|
||||
|
||||
navigation:
|
||||
header:
|
||||
children:
|
||||
- link: accounts.channels.children
|
||||
- link: accounts.publishingchannels.children
|
||||
- link: accounts.socialnetworks
|
||||
- name: Contact
|
||||
description: Get in touch
|
||||
icon:
|
||||
@@ -429,6 +466,12 @@ navigation:
|
||||
class: bi bi-clipboard2-check-fill
|
||||
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
|
||||
icon:
|
||||
class: fa-solid fa-comments
|
||||
@@ -450,16 +493,34 @@ navigation:
|
||||
icon:
|
||||
class: fa-solid fa-envelope
|
||||
url: https://mail.veen.world/
|
||||
- name: Tools
|
||||
- name: Administration
|
||||
icon:
|
||||
class: fas fa-tools
|
||||
class: fas fa-building
|
||||
children:
|
||||
- name: Matomo
|
||||
description: Analyze with Matomo
|
||||
icon:
|
||||
class: fa-solid fa-chart-simple
|
||||
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
|
||||
description: Organize with Baserow
|
||||
icon:
|
||||
@@ -476,7 +537,6 @@ navigation:
|
||||
icon:
|
||||
class: fa-solid fa-cloud
|
||||
url: https://cloud.veen.world/
|
||||
|
||||
- name: About Me
|
||||
description: All information about me
|
||||
icon:
|
||||
@@ -557,9 +617,67 @@ navigation:
|
||||
class: fa-solid fa-layer-group
|
||||
url: https://s.veen.world/skillmatrix
|
||||
- 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
|
||||
description: Check out the imprint information
|
||||
icon:
|
||||
class: fa-solid fa-scale-balanced
|
||||
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');
|
||||
});
|
||||
});
|
||||
130
app/cypress/e2e/menu.spec.js
Normal file
130
app/cypress/e2e/menu.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!');
|
||||
});
|
||||
});
|
||||
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 */
|
||||
.navbar, .card, .dropdown-menu{
|
||||
.navbar, .card, .dropdown-menu {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,18 @@ a {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -70,7 +82,6 @@ h3.card-title {
|
||||
|
||||
/* Footer styles */
|
||||
.footer {
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
@@ -84,3 +95,121 @@ h3.card-title {
|
||||
h3.footer-title {
|
||||
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 */
|
||||
div#navbarNavheader .navbar-nav,
|
||||
div#navbarNavfooter .navbar-nav {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
div#navbarNavheader .navbar-nav::-webkit-scrollbar,
|
||||
div#navbarNavfooter .navbar-nav::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
|
||||
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 {
|
||||
overflow: hidden;
|
||||
/* 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 */
|
||||
body.fullscreen header,
|
||||
body.fullscreen footer {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
/* Top-Level Dropdown-Menü */
|
||||
/* Top-level dropdown menu */
|
||||
.nav-item .dropdown-menu {
|
||||
position: absolute; /* Wichtig für Positionierung */
|
||||
top: 100%; /* Standardmäßige Öffnung nach unten */
|
||||
position: absolute; /* Important for positioning */
|
||||
top: 100%; /* Default opening direction: downwards */
|
||||
left: 0;
|
||||
z-index: 1050; /* Damit das Menü über anderen Elementen liegt */
|
||||
z-index: 1050; /* Ensures the menu appears above other elements */
|
||||
}
|
||||
|
||||
/* Submenu-Position */
|
||||
/* Submenu position */
|
||||
.dropdown-submenu > .dropdown-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%; /* Öffnen nach rechts */
|
||||
left: 100%; /* Opens to the right */
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
/* Sicherstellen, dass der Übergang smooth ist */
|
||||
/* Ensure a smooth transition */
|
||||
.dropdown-menu {
|
||||
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');
|
||||
linkHref.href = subitem.url;
|
||||
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 {
|
||||
linkBox.classList.add('d-none');
|
||||
linkHref.href = '#';
|
||||
|
||||
@@ -17,15 +17,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Öffnen beim Hovern
|
||||
// Open on hover
|
||||
item.addEventListener('mouseenter', onMouseEnter);
|
||||
|
||||
// Verzögertes Schließen beim Verlassen
|
||||
// Delayed close on mouse leave
|
||||
item.addEventListener('mouseleave', onMouseLeave);
|
||||
|
||||
// Öffnen und Position anpassen beim Klicken
|
||||
// Open and adjust position on click
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Verhindert das Schließen von Menüs bei Klick
|
||||
e.stopPropagation(); // Prevents menus from closing when clicking inside
|
||||
if (item.classList.contains('open')) {
|
||||
closeMenu(item);
|
||||
} else {
|
||||
@@ -44,7 +44,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
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', () => {
|
||||
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
|
||||
});
|
||||
@@ -70,11 +70,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function isSmallScreen() {
|
||||
return window.innerWidth < 992; // Bootstrap-Breakpoint für 'lg'
|
||||
}
|
||||
function isSmallScreen() {
|
||||
return window.innerWidth < 992; // Bootstrap breakpoint for 'lg'
|
||||
}
|
||||
|
||||
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
||||
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
||||
const rect = submenu.getBoundingClientRect();
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
|
||||
@@ -89,12 +89,12 @@ function adjustMenuPosition(submenu, parent, isTopLevel) {
|
||||
submenu.style.right = '';
|
||||
|
||||
if (isTopLevel) {
|
||||
if (isSmallScreen && spaceBelow < spaceAbove) {
|
||||
// Für kleine Bildschirme: Menü direkt über dem Eltern-Element öffnen
|
||||
if (isSmallScreen() && spaceBelow < spaceAbove) {
|
||||
// For small screens: Open menu directly above the parent element
|
||||
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-Menü
|
||||
// Top-level menu
|
||||
else if (spaceBelow < spaceAbove) {
|
||||
submenu.style.bottom = `${window.innerHeight - parentRect.bottom - parentRect.height}px`;
|
||||
submenu.style.top = 'auto';
|
||||
@@ -103,19 +103,19 @@ function adjustMenuPosition(submenu, parent, isTopLevel) {
|
||||
submenu.style.bottom = 'auto';
|
||||
}
|
||||
} else {
|
||||
// Submenü
|
||||
// Submenu
|
||||
const prefersRight = spaceRight >= spaceLeft;
|
||||
submenu.style.left = prefersRight ? '100%' : 'auto';
|
||||
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) {
|
||||
submenu.style.bottom = `0`;
|
||||
submenu.style.top = `auto`;
|
||||
} else {
|
||||
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 () {
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
|
||||
|
||||
@@ -1,32 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{company.titel}}</title>
|
||||
<title>{{platform.titel}}</title>
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<link rel="stylesheet" href="static/css/default.css">
|
||||
<script src="{{ url_for('static', filename='vendor/marked/marked.min.js') }}"></script>
|
||||
<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>
|
||||
<body>
|
||||
<body
|
||||
{% if apod_bg %}
|
||||
style="
|
||||
background-image: url('{{ apod_bg }}');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
"
|
||||
{% endif %}
|
||||
>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<img src="{{company.logo.cache}}" alt="logo"/>
|
||||
<h1>{{company.titel}}</h1>
|
||||
<h2>{{company.subtitel}}</h2>
|
||||
<br />
|
||||
<header class="header js-restore">
|
||||
<img
|
||||
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
||||
alt="logo"
|
||||
/>
|
||||
<h1>{{platform.titel}}</h1>
|
||||
<h2>{{platform.subtitel}}</h2>
|
||||
</header>
|
||||
{% set menu_type = "header" %}
|
||||
{% include "moduls/navigation.html.j2"%}
|
||||
<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" %}
|
||||
{% include "moduls/navigation.html.j2" %}
|
||||
<footer class="footer">
|
||||
@@ -34,14 +60,22 @@
|
||||
<p itemprop="name">{{ company.titel }} <br />
|
||||
{{ company.subtitel }}</p>
|
||||
<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>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Include modal -->
|
||||
{% include "moduls/modal.html.j2" %}
|
||||
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/navigation.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/tooltip.js') }}"></script>
|
||||
{% for name in [
|
||||
'modal',
|
||||
'navigation',
|
||||
'tooltip',
|
||||
'container',
|
||||
'fullwidth',
|
||||
'fullscreen',
|
||||
'iframe',
|
||||
] %}
|
||||
<script src="{{ url_for('static', filename='js/' ~ name ~ '.js') }}"></script>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,14 +1,31 @@
|
||||
<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-body d-flex flex-column">
|
||||
<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') %}
|
||||
{{ include_svg(card.icon.cache) }}
|
||||
{% elif card.icon.cache %}
|
||||
<img
|
||||
src="{{ url_for('static', filename=card.icon.cache) }}"
|
||||
alt="{{ card.title }}"
|
||||
style="width:100px; height:auto;"
|
||||
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
|
||||
{% if card.icon.class %}
|
||||
<i class="{{ card.icon.class }}" style="display:none;"></i>
|
||||
{% 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" ><i class="fa-solid fa-globe"></i> {{ card.link_text }}</a>
|
||||
<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 %}
|
||||
|
||||
@@ -8,26 +8,39 @@
|
||||
{% endmacro %}
|
||||
<!-- Template for children -->
|
||||
{% macro render_children(children) %}
|
||||
{% for children in children %}
|
||||
{% if children.children %}
|
||||
{% for child in children %}
|
||||
{% if child.children %}
|
||||
<li class="dropdown-submenu position-relative">
|
||||
<a class="dropdown-item dropdown-toggle" title="{{ children.description }}">
|
||||
{{ render_icon_and_name(children) }}
|
||||
<a class="dropdown-item dropdown-toggle" title="{{ child.description }}">
|
||||
{{ render_icon_and_name(child) }}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
{{ render_children(children.children) }}
|
||||
{{ render_children(child.children) }}
|
||||
</ul>
|
||||
</li>
|
||||
{% elif children.identifier or children.warning or children.info %}
|
||||
|
||||
{% elif child.identifier or child.warning or child.info %}
|
||||
<li>
|
||||
<a class="dropdown-item" onclick='openDynamicPopup({{ children|tojson|safe }})' data-bs-toggle="tooltip" title="{{ children.description }}">
|
||||
{{ render_icon_and_name(children) }}
|
||||
<a class="dropdown-item"
|
||||
onclick='openDynamicPopup({{ child|tojson|safe }})'
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ child.description }}">
|
||||
{{ render_icon_and_name(child) }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% else %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ children.url }}" target="{{ children.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ children.description }}">
|
||||
{{ render_icon_and_name(children) }}
|
||||
<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 %}
|
||||
@@ -35,18 +48,37 @@
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light menu-{{menu_type}} mb-0">
|
||||
<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>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
|
||||
{% if menu_type == "header" %}
|
||||
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
|
||||
<img
|
||||
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
||||
alt="{{ platform.titel }}"
|
||||
class="d-inline-block align-text-top"
|
||||
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 %}
|
||||
<!-- Single Item -->
|
||||
{% if item.url or item.onclick %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link btn btn-light" href="{{ item.url }}" target="{{ item.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ item.description }}">
|
||||
<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>
|
||||
@@ -68,5 +100,4 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
{% 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" %}
|
||||
{% endfor %}
|
||||
</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 mimetypes
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
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"):
|
||||
"""
|
||||
Initialize the CacheManager with a cache directory.
|
||||
|
||||
:param cache_dir: The directory where cached files will be stored.
|
||||
"""
|
||||
self.cache_dir = cache_dir
|
||||
self._ensure_cache_dir_exists()
|
||||
|
||||
def _ensure_cache_dir_exists(self):
|
||||
"""
|
||||
Ensure the cache directory exists. If it doesn't, create it.
|
||||
"""
|
||||
if not os.path.exists(self.cache_dir):
|
||||
os.makedirs(self.cache_dir)
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
Clear all files in the cache directory.
|
||||
"""
|
||||
if os.path.exists(self.cache_dir):
|
||||
for filename in os.listdir(self.cache_dir):
|
||||
file_path = os.path.join(self.cache_dir, filename)
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
path = os.path.join(self.cache_dir, filename)
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
|
||||
def cache_file(self, file_url):
|
||||
"""
|
||||
Download a file and store it locally in the cache directory with a hashed filename.
|
||||
hash_suffix = hashlib.blake2s(
|
||||
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.
|
||||
:return: The local path of the cached file.
|
||||
"""
|
||||
# Generate a hashed filename based on the URL
|
||||
hash_object = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8)
|
||||
hash_suffix = hash_object.hexdigest()
|
||||
try:
|
||||
resp = requests.get(file_url, stream=True, timeout=5)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
# Determine the base name for the file
|
||||
splitted_file_url = file_url.split("/")
|
||||
base_name = splitted_file_url[-2] if splitted_file_url[-1] == "download" else splitted_file_url[-1]
|
||||
|
||||
# Construct the full path for the cached file
|
||||
filename = f"{base_name}_{hash_suffix}.png"
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png"
|
||||
filename = f"{base}_{hash_suffix}{ext}"
|
||||
full_path = os.path.join(self.cache_dir, filename)
|
||||
|
||||
# If the file already exists, return the cached path
|
||||
if os.path.exists(full_path):
|
||||
return full_path
|
||||
if not os.path.exists(full_path):
|
||||
with open(full_path, "wb") as f:
|
||||
for chunk in resp.iter_content(1024):
|
||||
f.write(chunk)
|
||||
|
||||
# Download the file and save it locally
|
||||
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
|
||||
return f"cache/{filename}"
|
||||
|
||||
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:
|
||||
"""
|
||||
A class to resolve `link` entries in a nested configuration structure.
|
||||
@@ -14,22 +13,9 @@ class ConfigurationResolver:
|
||||
"""
|
||||
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):
|
||||
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):
|
||||
index = list_origine.index(old_element)
|
||||
@@ -43,27 +29,43 @@ class ConfigurationResolver:
|
||||
for key, value in list(current_config.items()):
|
||||
if key == "children":
|
||||
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:
|
||||
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):
|
||||
self._replace_in_list_by_list(value,item,loaded_link)
|
||||
self._replace_in_list_by_list(value, item, loaded_link)
|
||||
else:
|
||||
self._replace_element_in_list(value,item,loaded_link)
|
||||
self._replace_element_in_list(value, item, loaded_link)
|
||||
else:
|
||||
self._recursive_resolve(value, root_config)
|
||||
elif key == "link":
|
||||
try:
|
||||
loaded = self._find_entry(root_config, self._mapped_key(value), True)
|
||||
loaded = self._find_entry(
|
||||
root_config, self._mapped_key(value), False
|
||||
)
|
||||
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.update(loaded)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
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:
|
||||
self._recursive_resolve(value, root_config)
|
||||
@@ -71,18 +73,25 @@ class ConfigurationResolver:
|
||||
for item in current_config:
|
||||
self._recursive_resolve(item, root_config)
|
||||
|
||||
def _get_children(self,current):
|
||||
if isinstance(current, dict) and ("children" in current and current["children"]):
|
||||
def _get_children(self, current):
|
||||
if isinstance(current, dict) and (
|
||||
"children" in current and current["children"]
|
||||
):
|
||||
current = current["children"]
|
||||
return current
|
||||
|
||||
def _mapped_key(self,name):
|
||||
def _mapped_key(self, name):
|
||||
return name.replace(" ", "").lower()
|
||||
|
||||
def _find_by_name(self,current, part):
|
||||
def _find_by_name(self, current, part):
|
||||
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):
|
||||
@@ -90,43 +99,48 @@ class ConfigurationResolver:
|
||||
Finds an entry in the configuration by a dot-separated path.
|
||||
Supports both dictionaries and lists with `children` navigation.
|
||||
"""
|
||||
parts = path.split('.')
|
||||
parts = path.split(".")
|
||||
current = config
|
||||
for part in parts:
|
||||
if isinstance(current, list):
|
||||
# If children explicit declared just load 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:
|
||||
current = found
|
||||
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}"
|
||||
)
|
||||
else:
|
||||
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}"
|
||||
)
|
||||
elif isinstance(current, dict):
|
||||
# Case-insensitive dictionary lookup
|
||||
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:
|
||||
current = self._find_by_name(current["children"],part)
|
||||
if "children" not in current:
|
||||
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:
|
||||
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}"
|
||||
)
|
||||
else:
|
||||
current = current[key]
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
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:
|
||||
current = self._get_children(current)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
landingpage:
|
||||
portfolio:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: application-landingpage
|
||||
container_name: landingpage
|
||||
container_name: portfolio
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "${PORT:-5000}:${PORT:-5000}"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./app:/app
|
||||
restart: unless-stopped
|
||||
|
||||
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.1.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()
|
||||
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