16 Commits

Author SHA1 Message Date
39a41e561c Release version 1.2.0 2026-05-11 02:51:17 +02:00
4424db22cb fix(cypress): allow scrollTo on non-scrollable pages in footer dropup test
cy.scrollTo('bottom') threw on CI ("element is not scrollable") whenever
the rendered page fit inside the viewport. Pass ensureScrollable:false
so the call is a no-op on short pages — the footer is already in view
and the subsequent rect-position pre-check enforces the actual
precondition that chooseDirection() needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:38:06 +02:00
28a796e24f chore(claude): enable sandbox and consolidate bash allowlist
Activate the harness sandbox (enabled + autoAllowBashIfSandboxed +
filesystem write/deny rules) and replace the ~30 specific Bash(...)
permission entries with a single Bash(*) wildcard. The existing deny
list (git push --force, git reset --hard, rm -rf, sudo) and ask list
(git push, docker run, curl) keep their precedence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:36:28 +02:00
f3c15e3e1c fix(navigation): unclip dropdowns and flip toward the side with more space
- Move <header> overflow:hidden into body.fullscreen scope and drop the
  implicit-vertical-clip overflow-x:auto from .navbar-nav so dropdown
  menus can escape the navbar.
- Drive top-level dropdowns through bootstrap.Dropdown (popperConfig
  strategy:'fixed'), and add a chooseDirection() helper that toggles
  .dropup/.dropdown on the .nav-item based on space above vs below
  before each show. Split the navigation.css rules to position the menu
  with top:100% or bottom:100% accordingly.
- Mark the dropdown toggle with data-bs-toggle="dropdown" in the
  template; cover that with a Jinja-rendered unit test and add Cypress
  specs for the header (opens downward) and footer (flips to .dropup)
  cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:26:02 +02:00
3301f8d95f chore(claude): allow jobs bash command in harness
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:22:06 +02:00
a575fddaa2 build(compose): run npm install in container, persist deps in named volumes
Replace the host-side `make npm-install` step with a compose-level
`command:` override that runs `npm install` inside the container on
every start. Back node_modules and static/vendor with named volumes so
the bind-mounted source tree no longer shadows the install while state
still survives container restarts. Drop the now-redundant npm-install
target and its references in up/dev/prod/test-e2e.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:17:53 +02:00
c9fe7d8583 chore(claude): expand harness allowlist and ignore local state
Add permissions for read-only test/inspection commands (make test-e2e,
docker exec/restart, /tmp reads) and gitignore everything under .claude
except the shared settings/gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:15:04 +02:00
03f17a6e05 build(make): run npm-install before docker-compose targets
Add npm-install dependency to up/dev/prod targets so app/static/vendor
and app/node_modules exist on the host before the ./app:/app bind mount
masks the build-time artifacts inside the container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:40:19 +02:00
3132aab2a5 Release version 1.1.0 2026-03-30 10:47:32 +02:00
3d1db1f8ba Updated mirrors 2026-03-30 10:46:39 +02:00
58872ced81 fix(ci): grant security-events: write to lint job
The lint-docker job in lint.yml requires security-events: write
for SARIF upload; must be explicitly granted to the caller job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:18:14 +02:00
13b3af3330 Entered and removed whitespaces in README.md 2026-03-30 10:16:53 +02:00
eca7084f4e fix(ci): grant security-events and packages permissions to security job
Reusable workflow calls inherit only explicitly granted permissions.
The nested security job requires packages: read and security-events: write
for CodeQL analysis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:16:30 +02:00
6861b2c0eb fix(nav): prevent navbar items from wrapping to second line
- Set flex-wrap: nowrap on navbar-nav to keep all items in one row
- Add hidden overflow-x scroll (no visible scrollbar) as fallback
- Fix #navbar_logo taking up space when invisible via max-width transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:10:55 +02:00
66b1f0d029 feat(agents): add AGENTS.md and CLAUDE.md with pre-commit rules
- Add AGENTS.md: require make test before every non-doc commit and
  document the npm vendor asset workflow
- Add CLAUDE.md: instruct agents to read AGENTS.md at conversation start
- Add npm-install dependency to test-e2e Makefile target

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:59:09 +02:00
a29a0b1862 feat(vendor): replace CDN dependencies with local npm packages
Introduces a vendor build pipeline so all third-party browser assets
(Bootstrap, Bootstrap Icons, Font Awesome, marked, jQuery) are served
from local static files instead of external CDNs.

- Add app/package.json with vendor deps and postinstall/build scripts
- Add app/scripts/copy-vendor.js to copy assets to static/vendor/
- Update base.html.j2 to use url_for('static', ...) for all vendor assets
- Update Dockerfile to install Node.js/npm and run npm install
- Update .gitignore to exclude app/node_modules/ and app/static/vendor/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:29:28 +02:00
23 changed files with 449 additions and 99 deletions

3
.claude/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*
!.gitignore
!.settings.json

View File

@@ -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": [
".",

0
.codex Normal file
View File

View File

@@ -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
View File

@@ -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
View 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.

View File

@@ -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
View 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.

View File

@@ -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"]

View File

@@ -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

View File

@@ -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.

View File

@@ -1,9 +1,6 @@
# PortUI 🖥️✨
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach)
[![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach)
[![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](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.

View File

@@ -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 = {

View File

@@ -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"
}
}

View 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')
);

View File

@@ -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 thats >= 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;

View File

@@ -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 */

View File

@@ -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,39 +109,59 @@ 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) {
submenu.style.display = 'block';
submenu.style.opacity = '1';
submenu.style.visibility = 'visible';
adjustMenuPosition(submenu, item, isTopLevel);
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) {
submenu.style.display = 'none';
submenu.style.opacity = '0';
submenu.style.visibility = 'hidden';
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() {

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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:

View File

@@ -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"

View 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()