mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-05-14 09:15:32 +00:00
Compare commits
16 Commits
252b50d2a7
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 39a41e561c | |||
| 4424db22cb | |||
| 28a796e24f | |||
| f3c15e3e1c | |||
| 3301f8d95f | |||
| a575fddaa2 | |||
| c9fe7d8583 | |||
| 03f17a6e05 | |||
| 3132aab2a5 | |||
| 3d1db1f8ba | |||
| 58872ced81 | |||
| 13b3af3330 | |||
| eca7084f4e | |||
| 6861b2c0eb | |||
| 66b1f0d029 | |||
| a29a0b1862 |
3
.claude/.gitignore
vendored
Normal file
3
.claude/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.settings.json
|
||||
@@ -4,52 +4,8 @@
|
||||
"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*)",
|
||||
"Bash(*)",
|
||||
"Read(//tmp/**)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
@@ -57,21 +13,28 @@
|
||||
"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*)"
|
||||
"WebFetch(domain:flask.palletsprojects.com)",
|
||||
"Skill(update-config)",
|
||||
"Skill(update-config:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(rm -rf*)",
|
||||
"Bash(sudo*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push*)",
|
||||
"Bash(docker run*)",
|
||||
"Bash(curl*)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/tmp"
|
||||
]
|
||||
},
|
||||
"sandbox": {
|
||||
"enabled": true,
|
||||
"autoAllowBashIfSandboxed": true,
|
||||
"filesystem": {
|
||||
"allowWrite": [
|
||||
".",
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -15,6 +15,10 @@ jobs:
|
||||
security:
|
||||
name: Run security workflow
|
||||
uses: ./.github/workflows/security.yml
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
security-events: write
|
||||
|
||||
tests:
|
||||
name: Run test workflow
|
||||
@@ -23,6 +27,9 @@ jobs:
|
||||
lint:
|
||||
name: Run lint workflow
|
||||
uses: ./.github/workflows/lint.yml
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
publish:
|
||||
name: Publish image
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ app/static/cache/*
|
||||
app/cypress/screenshots/*
|
||||
.ruff_cache/
|
||||
app/node_modules/
|
||||
app/static/vendor/
|
||||
hadolint-results.sarif
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
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.
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,3 +1,22 @@
|
||||
## [1.2.0] - 2026-05-11
|
||||
|
||||
* * Navigation behavior: Top-level dropdowns now open reliably on hover and click via Bootstrap, escape the header and navbar overflow clips, and flip between downward and upward based on whether more space is above or below the toggle
|
||||
* Compose-driven dependencies: docker-compose runs npm install inside the container on every up and persists node_modules plus static/vendor in named volumes, removing the host-side npm-install step from up, dev, prod, and test-e2e
|
||||
* Test coverage: New Cypress specs cover both header and footer dropdown directions, with a Jinja unit test guarding the data-bs-toggle attribute on top-level dropdown toggles
|
||||
* Harness configuration: Enabled the Claude Code sandbox with scoped filesystem rules, consolidated the bash allowlist behind a single wildcard, and gitignored local-only state under .claude
|
||||
|
||||
|
||||
## [1.1.0] - 2026-03-30
|
||||
|
||||
* *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads
|
||||
* *Modern Python packaging*: Migration to pyproject.toml and updated Dockerfile using Python 3.12
|
||||
* *Improved test coverage*: Added unit, integration, lint, security, and E2E tests using act
|
||||
* *Local vendor assets*: Replaced external CDNs with npm-based local asset pipeline
|
||||
* *Enhanced build workflow*: Extended Makefile with targets for test, lint, security, and CI plus vendor build process
|
||||
* *Frontend fix*: Prevented navbar wrapping and improved layout behavior
|
||||
* *Developer guidelines*: Introduced AGENTS.md and CLAUDE.md with enforced pre-commit rules
|
||||
|
||||
|
||||
## [1.0.0] - 2026-02-19
|
||||
|
||||
* Official Release🥳
|
||||
|
||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Startup
|
||||
|
||||
You MUST read `AGENTS.md` and follow all instructions in it at the start of every conversation before doing anything else.
|
||||
@@ -4,6 +4,9 @@ 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 ./
|
||||
@@ -12,5 +15,6 @@ RUN python -m pip install --no-cache-dir .
|
||||
|
||||
WORKDIR /app
|
||||
COPY app/ .
|
||||
RUN npm install --prefix /app
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
4
MIRRORS
4
MIRRORS
@@ -1,2 +1,4 @@
|
||||
https://pypi.org/project/portfolio-ui/
|
||||
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
|
||||
|
||||
|
||||
5
Makefile
5
Makefile
@@ -92,11 +92,6 @@ 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.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# PortUI 🖥️✨
|
||||
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||
[](https://s.veen.world/paypaldonate)
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||
|
||||
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
|
||||
|
||||
|
||||
@@ -1,4 +1,70 @@
|
||||
// cypress/e2e/dynamic_popup.spec.js
|
||||
// cypress/e2e/menu.spec.js
|
||||
|
||||
describe('Navigation dropdowns', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1280, 720);
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('opens top-level dropdowns with explicit Bootstrap instances', () => {
|
||||
cy.get('#navbarNavheader .nav-item.dropdown > .nav-link.dropdown-toggle')
|
||||
.first()
|
||||
.as('toggle')
|
||||
.should('have.attr', 'data-bs-toggle', 'dropdown')
|
||||
.and('have.attr', 'aria-expanded', 'false');
|
||||
|
||||
cy.get('@toggle').then($toggle => {
|
||||
cy.window().then(win => {
|
||||
expect(win.bootstrap.Dropdown.getInstance($toggle[0])).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('@toggle')
|
||||
.parent('.nav-item')
|
||||
.find('> .dropdown-menu')
|
||||
.as('menu')
|
||||
.should('not.have.class', 'show')
|
||||
.should('not.be.visible');
|
||||
|
||||
cy.get('@toggle').click();
|
||||
cy.get('@toggle').should('have.attr', 'aria-expanded', 'true');
|
||||
cy.get('@toggle').parent('.nav-item').should('have.class', 'dropdown');
|
||||
cy.get('@menu')
|
||||
.should('have.class', 'show')
|
||||
.and('be.visible');
|
||||
});
|
||||
|
||||
it('flips footer dropdowns upward where there is more space above', () => {
|
||||
cy.get('#navbarNavfooter .nav-item .nav-link.dropdown-toggle')
|
||||
.first()
|
||||
.as('toggle');
|
||||
|
||||
cy.get('@toggle')
|
||||
.parent('.nav-item')
|
||||
.as('item')
|
||||
.find('> .dropdown-menu')
|
||||
.as('menu')
|
||||
.should('not.have.class', 'show');
|
||||
|
||||
// Make sure the footer sits at the bottom of the viewport before clicking
|
||||
// — otherwise the toggle could land near the top and chooseDirection would
|
||||
// keep .dropdown (more space below than above).
|
||||
// ensureScrollable:false because on short pages the body isn't scrollable
|
||||
// and the footer is already in view (which is fine for this test).
|
||||
cy.scrollTo('bottom', { ensureScrollable: false });
|
||||
cy.get('@toggle').then($toggle => {
|
||||
const rect = $toggle[0].getBoundingClientRect();
|
||||
expect(rect.top, 'toggle is in the lower half of the viewport')
|
||||
.to.be.greaterThan(Cypress.config('viewportHeight') / 2);
|
||||
});
|
||||
cy.get('@toggle').click({ scrollBehavior: false });
|
||||
cy.get('@item').should('have.class', 'dropup');
|
||||
cy.get('@item').should('not.have.class', 'dropdown');
|
||||
cy.get('@menu')
|
||||
.should('have.class', 'show')
|
||||
.and('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Popup', () => {
|
||||
const base = {
|
||||
|
||||
@@ -1,5 +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"
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
);
|
||||
@@ -111,6 +111,15 @@ div#navbarNavfooter li.nav-item {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* Prevent nav items from wrapping to a second line.
|
||||
overflow is intentionally NOT set here — overflow-x:auto would
|
||||
implicitly clip overflow-y too and hide dropdown menus that open
|
||||
below the navbar. */
|
||||
div#navbarNavheader .navbar-nav,
|
||||
div#navbarNavfooter .navbar-nav {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
main, footer, header, nav {
|
||||
position: relative;
|
||||
box-shadow:
|
||||
@@ -168,20 +177,22 @@ iframe{
|
||||
}
|
||||
|
||||
#navbar_logo {
|
||||
/* start invisible but in the layout (d-none will actually hide it) */
|
||||
opacity: 0;
|
||||
transition: opacity var(--anim-duration) ease-in-out;
|
||||
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;
|
||||
@@ -190,9 +201,11 @@ footer {
|
||||
padding var(--anim-duration) ease-in-out;
|
||||
}
|
||||
|
||||
/* 2. In fullscreen mode, collapse them */
|
||||
/* 2. In fullscreen mode, collapse them. overflow: hidden is scoped here
|
||||
so dropdown menus can escape the header in normal mode. */
|
||||
body.fullscreen header,
|
||||
body.fullscreen footer {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
/* Top-level dropdown menu */
|
||||
.nav-item .dropdown-menu {
|
||||
position: absolute; /* Important for positioning */
|
||||
top: 100%; /* Default opening direction: downwards */
|
||||
/* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */
|
||||
.nav-item.dropdown > .dropdown-menu,
|
||||
.nav-item.dropup > .dropdown-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
z-index: 1050; /* Ensures the menu appears above other elements */
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.nav-item.dropdown > .dropdown-menu {
|
||||
top: 100%;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.nav-item.dropup > .dropdown-menu {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
}
|
||||
|
||||
/* Submenu position */
|
||||
|
||||
@@ -1,6 +1,58 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const menuItems = document.querySelectorAll('.nav-item.dropdown');
|
||||
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||
function getDirectChildByClass(item, className) {
|
||||
return Array.from(item.children).find(child => child.classList?.contains(className));
|
||||
}
|
||||
|
||||
function getMenu(item) {
|
||||
return getDirectChildByClass(item, 'dropdown-menu');
|
||||
}
|
||||
|
||||
function getToggle(item) {
|
||||
return getDirectChildByClass(item, 'dropdown-toggle');
|
||||
}
|
||||
|
||||
function isTopLevelDropdown(item) {
|
||||
return (
|
||||
item.classList.contains('nav-item') &&
|
||||
(item.classList.contains('dropdown') || item.classList.contains('dropup'))
|
||||
);
|
||||
}
|
||||
|
||||
function chooseDirection(item) {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
if (spaceAbove > spaceBelow) {
|
||||
item.classList.add('dropup');
|
||||
item.classList.remove('dropdown');
|
||||
} else {
|
||||
item.classList.add('dropdown');
|
||||
item.classList.remove('dropup');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDropdownInstances(root = document) {
|
||||
const scope = root && root.querySelectorAll ? root : document;
|
||||
scope
|
||||
.querySelectorAll('.nav-item.dropdown > .dropdown-toggle, .nav-item.dropup > .dropdown-toggle')
|
||||
.forEach(toggle => {
|
||||
toggle.setAttribute('data-bs-toggle', 'dropdown');
|
||||
if (!toggle.hasAttribute('aria-expanded')) {
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
if (window.bootstrap?.Dropdown) {
|
||||
// Use Popper strategy: 'fixed' so the menu is positioned relative
|
||||
// to the viewport and escapes ancestors with overflow:hidden
|
||||
// (e.g. <header> which clips for the fullscreen-collapse animation).
|
||||
window.bootstrap.Dropdown.getInstance(toggle)?.dispose();
|
||||
new window.bootstrap.Dropdown(toggle, {
|
||||
popperConfig(defaultBsPopperConfig) {
|
||||
return { ...defaultBsPopperConfig, strategy: 'fixed' };
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addMenuEventListeners(items, isTopLevel) {
|
||||
items.forEach(item => {
|
||||
@@ -8,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
function onMouseEnter() {
|
||||
clearTimeout(timeout);
|
||||
openMenu(item, isTopLevel);
|
||||
openMenu(item, isTopLevel, 'hover');
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
@@ -25,7 +77,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Open and adjust position on click
|
||||
item.addEventListener('click', (e) => {
|
||||
const toggle = getToggle(item);
|
||||
const clickedToggle = toggle && (e.target === toggle || toggle.contains(e.target));
|
||||
|
||||
if (isTopLevel && !clickedToggle) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation(); // Prevents menus from closing when clicking inside
|
||||
if (isTopLevel) {
|
||||
e.preventDefault();
|
||||
if (window.bootstrap?.Dropdown) {
|
||||
if (item.dataset.openedBy === 'click') {
|
||||
closeMenu(item);
|
||||
} else if (getMenu(item)) {
|
||||
item.dataset.openedBy = 'click';
|
||||
item.classList.add('open');
|
||||
chooseDirection(item);
|
||||
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (item.classList.contains('open')) {
|
||||
closeMenu(item);
|
||||
} else {
|
||||
@@ -35,40 +109,60 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
const TOP_LEVEL_SELECTOR = '.nav-item.dropdown, .nav-item.dropup';
|
||||
|
||||
function addAllMenuEventListeners() {
|
||||
const updatedMenuItems = document.querySelectorAll('.nav-item.dropdown');
|
||||
const updatedMenuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
|
||||
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||
addMenuEventListeners(updatedMenuItems, true);
|
||||
addMenuEventListeners(updatedSubMenuItems, false);
|
||||
}
|
||||
|
||||
ensureDropdownInstances();
|
||||
addAllMenuEventListeners();
|
||||
|
||||
// Global click listener to close menus when clicking outside
|
||||
document.addEventListener('click', () => {
|
||||
const menuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
|
||||
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
|
||||
});
|
||||
|
||||
function openMenu(item, isTopLevel) {
|
||||
function openMenu(item, isTopLevel, openedBy = 'script') {
|
||||
item.classList.add('open');
|
||||
const submenu = item.querySelector('.dropdown-menu');
|
||||
if (submenu) {
|
||||
const submenu = getMenu(item);
|
||||
if (!submenu) return;
|
||||
if (isTopLevel) {
|
||||
item.dataset.openedBy = openedBy;
|
||||
const toggle = getToggle(item);
|
||||
if (toggle && window.bootstrap?.Dropdown) {
|
||||
chooseDirection(item);
|
||||
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
|
||||
return;
|
||||
}
|
||||
}
|
||||
submenu.style.display = 'block';
|
||||
submenu.style.opacity = '1';
|
||||
submenu.style.visibility = 'visible';
|
||||
adjustMenuPosition(submenu, item, isTopLevel);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu(item) {
|
||||
item.classList.remove('open');
|
||||
const submenu = item.querySelector('.dropdown-menu');
|
||||
if (submenu) {
|
||||
delete item.dataset.openedBy;
|
||||
const submenu = getMenu(item);
|
||||
if (!submenu) return;
|
||||
if (isTopLevelDropdown(item)) {
|
||||
const toggle = getToggle(item);
|
||||
if (toggle && window.bootstrap?.Dropdown) {
|
||||
window.bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
|
||||
return;
|
||||
}
|
||||
}
|
||||
submenu.style.display = 'none';
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.visibility = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
function isSmallScreen() {
|
||||
return window.innerWidth < 992; // Bootstrap breakpoint for 'lg'
|
||||
|
||||
@@ -9,22 +9,19 @@
|
||||
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>
|
||||
<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="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='vendor/jquery/jquery.min.js') }}"></script>
|
||||
</head>
|
||||
<body
|
||||
{% if apod_bg %}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{% else %}
|
||||
<!-- Dropdown Menu -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-display="dynamic" aria-expanded="false">
|
||||
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false">
|
||||
{% if item.icon is defined and item.icon.class is defined %}
|
||||
{{ render_icon_and_name(item) }}
|
||||
{% else %}
|
||||
|
||||
@@ -12,4 +12,13 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./app:/app
|
||||
- node_modules:/app/node_modules
|
||||
- vendor:/app/static/vendor
|
||||
# Run `npm install` on every container start so the named volumes
|
||||
# reflect the current package.json (postinstall regenerates vendor/).
|
||||
command: sh -c "npm install --prefix /app --no-audit --no-fund && python app.py"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
vendor:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portfolio-ui"
|
||||
version = "0.0.0"
|
||||
version = "1.2.0"
|
||||
description = "A lightweight YAML-driven portfolio and landing-page generator."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
70
tests/unit/test_navigation_template.py
Normal file
70
tests/unit/test_navigation_template.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
|
||||
class AnchorCollector(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.anchors = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "a":
|
||||
self.anchors.append(dict(attrs))
|
||||
|
||||
|
||||
class TestNavigationTemplate(unittest.TestCase):
|
||||
def test_top_level_dropdowns_have_bootstrap_toggle_attribute(self):
|
||||
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
|
||||
environment = Environment(
|
||||
loader=FileSystemLoader(template_dir),
|
||||
autoescape=select_autoescape(),
|
||||
)
|
||||
environment.globals["url_for"] = lambda _endpoint, filename: (
|
||||
f"/static/{filename}"
|
||||
)
|
||||
|
||||
rendered = environment.get_template("moduls/navigation.html.j2").render(
|
||||
menu_type="header",
|
||||
platform={
|
||||
"titel": "Portfolio",
|
||||
"logo": {"cache": "logo.png"},
|
||||
},
|
||||
navigation={
|
||||
"header": {
|
||||
"children": [
|
||||
{
|
||||
"name": "Apps",
|
||||
"description": "Application menu",
|
||||
"icon": {"class": "fa-solid fa-grid"},
|
||||
"children": [
|
||||
{
|
||||
"name": "Example",
|
||||
"description": "Example app",
|
||||
"icon": {"class": "fa-solid fa-link"},
|
||||
"url": "https://example.test",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
parser = AnchorCollector()
|
||||
parser.feed(rendered)
|
||||
dropdown_toggles = [
|
||||
anchor
|
||||
for anchor in parser.anchors
|
||||
if "nav-link" in anchor.get("class", "")
|
||||
and "dropdown-toggle" in anchor.get("class", "")
|
||||
]
|
||||
|
||||
self.assertEqual(len(dropdown_toggles), 1)
|
||||
self.assertEqual(dropdown_toggles[0].get("data-bs-toggle"), "dropdown")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user