mirror of
				https://github.com/kevinveenbirkenbach/homepage.veen.world.git
				synced 2025-11-03 17:08:00 +00:00 
			
		
		
		
	Added cypress tests
This commit is contained in:
		
							
								
								
									
										82
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,82 @@
 | 
			
		||||
# 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
 | 
			
		||||
 | 
			
		||||
# Default port (can be overridden with PORT env var)
 | 
			
		||||
.PHONY: build
 | 
			
		||||
build:
 | 
			
		||||
	# Build the Docker image.
 | 
			
		||||
	docker build -t application-portfolio .
 | 
			
		||||
 | 
			
		||||
.PHONY: up
 | 
			
		||||
up:
 | 
			
		||||
	# Start the application using docker-compose with build.
 | 
			
		||||
	docker-compose up -d --build
 | 
			
		||||
 | 
			
		||||
.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)
 | 
			
		||||
 | 
			
		||||
# Cypress tests\ nCYPRESS_DIR := app
 | 
			
		||||
.PHONY: test
 | 
			
		||||
test: down prod
 | 
			
		||||
	# Run end-to-end tests with Cypress.
 | 
			
		||||
	cd app && npx cypress run --spec "cypress/e2e/**/*.spec.js"
 | 
			
		||||
							
								
								
									
										2
									
								
								app/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								app/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
node_modules/
 | 
			
		||||
package-lock.json
 | 
			
		||||
							
								
								
									
										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!');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										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!');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										5
									
								
								app/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "cypress": "^14.5.1"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -44,8 +44,8 @@ function enterFullscreen() {
 | 
			
		||||
  // fade in logo… (unchanged)
 | 
			
		||||
  const logo = document.getElementById('navbar_logo');
 | 
			
		||||
  if (logo) {
 | 
			
		||||
    logo.classList.remove('d-none');
 | 
			
		||||
    requestAnimationFrame(() => logo.style.opacity = '1');
 | 
			
		||||
    // hide the navbar‐logo restore link in fullscreen
 | 
			
		||||
    logo.classList.add('d-none');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // now recalc in lock-step with the CSS collapse animation
 | 
			
		||||
@@ -57,16 +57,10 @@ function exitFullscreen() {
 | 
			
		||||
  setFullWidth(false);
 | 
			
		||||
  updateUrlFullscreen(false);
 | 
			
		||||
 | 
			
		||||
  // fade out logo… (unchanged)
 | 
			
		||||
  const logo = document.getElementById('navbar_logo');
 | 
			
		||||
  if (logo) {
 | 
			
		||||
    logo.style.opacity = '0';
 | 
			
		||||
    logo.addEventListener('transitionend', function handler(e) {
 | 
			
		||||
      if (e.propertyName === 'opacity') {
 | 
			
		||||
        logo.classList.add('d-none');
 | 
			
		||||
        logo.removeEventListener('transitionend', handler);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    // show the navbar‐logo restore link again
 | 
			
		||||
    logo.classList.remove('d-none');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // recalc while header/footer expand back
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,16 @@
 | 
			
		||||
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");
 | 
			
		||||
@@ -24,8 +34,6 @@ function syncIframeHeight() {
 | 
			
		||||
 | 
			
		||||
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
 | 
			
		||||
function openIframe(url) {
 | 
			
		||||
    enterFullscreen();
 | 
			
		||||
 | 
			
		||||
    var $container    = scrollbarContainer    ? $(scrollbarContainer)    : null;
 | 
			
		||||
    var $customScroll = customScrollbar       ? $(customScrollbar)       : null;
 | 
			
		||||
    var $main         = $(mainElement);
 | 
			
		||||
@@ -36,7 +44,9 @@ function openIframe(url) {
 | 
			
		||||
    if ($customScroll) promises.push($customScroll.fadeOut(1500).promise());
 | 
			
		||||
 | 
			
		||||
    $.when.apply($, promises).done(function() {
 | 
			
		||||
        // Iframe anlegen, falls noch nicht vorhanden
 | 
			
		||||
        // 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;
 | 
			
		||||
@@ -63,36 +73,31 @@ function openIframe(url) {
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Function to restore the original content (jQuery version mit 1500 ms Fade)
 | 
			
		||||
/**
 | 
			
		||||
 * Restore the original <main> content and exit fullscreen.
 | 
			
		||||
 */
 | 
			
		||||
function restoreOriginal() {
 | 
			
		||||
    var $main   = $(mainElement);
 | 
			
		||||
    var $iframe = $main.find('iframe');
 | 
			
		||||
    var $container    = scrollbarContainer    ? $(scrollbarContainer)    : null;
 | 
			
		||||
    var $customScroll = customScrollbar       ? $(customScrollbar)       : null;
 | 
			
		||||
  // Exit fullscreen (collapse header/footer and run recalcs)
 | 
			
		||||
  exitFullscreen();
 | 
			
		||||
 | 
			
		||||
    if ($iframe.length) {
 | 
			
		||||
        // Iframe mit 1500 ms ausblenden, dann entfernen und Original einblenden
 | 
			
		||||
        $iframe.fadeOut(1500, function() {
 | 
			
		||||
            $iframe.remove();
 | 
			
		||||
  // Replace <main> innerHTML with the snapshot we took on load
 | 
			
		||||
  mainElement.innerHTML = originalContent;
 | 
			
		||||
 | 
			
		||||
            if ($container)    $container.fadeIn(1500);
 | 
			
		||||
            if ($customScroll) $customScroll.fadeIn(1500);
 | 
			
		||||
 | 
			
		||||
            // Inline-Style des main-Elements zurücksetzen
 | 
			
		||||
  // Reset any inline styles on mainElement
 | 
			
		||||
  if (originalMainStyle !== null) {
 | 
			
		||||
                $main.attr('style', originalMainStyle);
 | 
			
		||||
    mainElement.setAttribute('style', originalMainStyle);
 | 
			
		||||
  } else {
 | 
			
		||||
                $main.removeAttr('style');
 | 
			
		||||
    mainElement.removeAttribute('style');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
            // URL-Parameter entfernen
 | 
			
		||||
            var newUrl = new URL(window.location);
 | 
			
		||||
            newUrl.searchParams.delete('iframe');
 | 
			
		||||
            window.history.pushState({}, '', newUrl);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
  // 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() {
 | 
			
		||||
@@ -109,14 +114,18 @@ document.addEventListener("DOMContentLoaded", function() {
 | 
			
		||||
        el.addEventListener("click", restoreOriginal);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // === Close iframe & exit fullscreen on any .js-restore click ===
 | 
			
		||||
    document.body.addEventListener('click', e => {
 | 
			
		||||
    if (e.target.closest('.js-restore')) {
 | 
			
		||||
        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);
 | 
			
		||||
    }
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
@@ -163,12 +172,18 @@ function observeIframeNavigation() {
 | 
			
		||||
  }, 500);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Remember and open via central toggle
 | 
			
		||||
// 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);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,2 +1,2 @@
 | 
			
		||||
PORT=5000
 | 
			
		||||
PORT=5001
 | 
			
		||||
FLASK_ENV=production
 | 
			
		||||
							
								
								
									
										300
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										300
									
								
								main.py
									
									
									
									
									
								
							@@ -1,300 +1,84 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
"""
 | 
			
		||||
main.py - A CLI tool for managing the Portfolio CMS Docker application.
 | 
			
		||||
 | 
			
		||||
This script provides commands to build and run the Docker container for the
 | 
			
		||||
portfolio application. It mimics the functionality of a Makefile with additional
 | 
			
		||||
explanatory text using argparse.
 | 
			
		||||
 | 
			
		||||
Commands:
 | 
			
		||||
    build     - Build the Docker image.
 | 
			
		||||
    up        - Start the application using docker-compose (with build).
 | 
			
		||||
    down      - Stop and remove the running container.
 | 
			
		||||
    run-dev   - Run the container in development mode (with hot-reloading).
 | 
			
		||||
    run-prod  - Run the container in production mode.
 | 
			
		||||
    logs      - Display the logs of the running container.
 | 
			
		||||
    dev       - Start the application in development mode using docker-compose.
 | 
			
		||||
    prod      - Start the application in production mode using docker-compose.
 | 
			
		||||
    cleanup   - Remove all stopped containers.
 | 
			
		||||
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 subprocess
 | 
			
		||||
import sys
 | 
			
		||||
import os
 | 
			
		||||
from dotenv import load_dotenv
 | 
			
		||||
import re
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
 | 
			
		||||
dotenv_path = Path(__file__).resolve().parent / ".env"
 | 
			
		||||
MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
 | 
			
		||||
 | 
			
		||||
if dotenv_path.exists():
 | 
			
		||||
    load_dotenv(dotenv_path)
 | 
			
		||||
else:
 | 
			
		||||
    print(f"⚠️  Warning: No .env file found at {dotenv_path}")
 | 
			
		||||
PORT = int(os.getenv("PORT", 5000))
 | 
			
		||||
 | 
			
		||||
def run_command(command, dry_run=False, env=None):
 | 
			
		||||
    """Utility function to run a shell command."""
 | 
			
		||||
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') as f:
 | 
			
		||||
        lines = f.readlines()
 | 
			
		||||
    for idx, line in enumerate(lines):
 | 
			
		||||
        m = pattern.match(line)
 | 
			
		||||
        if m:
 | 
			
		||||
            name = m.group(1)
 | 
			
		||||
            help_text = ''
 | 
			
		||||
            # look for next non-empty line
 | 
			
		||||
            if idx + 1 < len(lines) and lines[idx+1].lstrip().startswith('#'):
 | 
			
		||||
                help_text = lines[idx+1].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, env=env)
 | 
			
		||||
        subprocess.check_call(command)
 | 
			
		||||
    except subprocess.CalledProcessError as e:
 | 
			
		||||
        print(f"Error: Command failed with exit code {e.returncode}")
 | 
			
		||||
        sys.exit(e.returncode)
 | 
			
		||||
 | 
			
		||||
def build(args):
 | 
			
		||||
    """
 | 
			
		||||
    Build the Docker image for the portfolio application.
 | 
			
		||||
    
 | 
			
		||||
    Command:
 | 
			
		||||
      docker build -t application-portfolio .
 | 
			
		||||
      
 | 
			
		||||
    This command creates a Docker image named 'application-portfolio'
 | 
			
		||||
    from the Dockerfile in the current directory.
 | 
			
		||||
    """
 | 
			
		||||
    command = ["docker", "build", "-t", "application-portfolio", "."]
 | 
			
		||||
    run_command(command, args.dry_run)
 | 
			
		||||
 | 
			
		||||
def up(args):
 | 
			
		||||
    """
 | 
			
		||||
    Start the application using docker-compose with build.
 | 
			
		||||
    
 | 
			
		||||
    Command:
 | 
			
		||||
      docker-compose up --build
 | 
			
		||||
      
 | 
			
		||||
    This command uses docker-compose to build (if necessary) and start
 | 
			
		||||
    all defined services. It is useful for quickly starting your
 | 
			
		||||
    development or production environment.
 | 
			
		||||
    """
 | 
			
		||||
    command = ["docker-compose", "up", "--build"]
 | 
			
		||||
    run_command(command, args.dry_run)
 | 
			
		||||
 | 
			
		||||
def down(args):
 | 
			
		||||
    """
 | 
			
		||||
    Stop and remove the Docker container named 'portfolio'.
 | 
			
		||||
    
 | 
			
		||||
    Commands:
 | 
			
		||||
      docker stop portfolio
 | 
			
		||||
      docker rm portfolio
 | 
			
		||||
      
 | 
			
		||||
    These commands stop the running container and remove it from your Docker host.
 | 
			
		||||
    The '-' prefix is used to ignore errors if the container is not running.
 | 
			
		||||
    """
 | 
			
		||||
    command_stop = ["docker", "stop", "portfolio"]
 | 
			
		||||
    command_rm = ["docker", "rm", "portfolio"]
 | 
			
		||||
    run_command(command_stop, args.dry_run)
 | 
			
		||||
    run_command(command_rm, args.dry_run)
 | 
			
		||||
 | 
			
		||||
def run_dev(args):
 | 
			
		||||
    """
 | 
			
		||||
    Run the container in development mode with hot-reloading.
 | 
			
		||||
    
 | 
			
		||||
    Command:
 | 
			
		||||
      docker run -d -p 5000:5000 --name portfolio -v $(pwd)/app/:/app \
 | 
			
		||||
      -e FLASK_APP=app.py -e FLASK_ENV=development application-portfolio
 | 
			
		||||
      
 | 
			
		||||
    This command starts the container in detached mode (-d), maps port 5000,
 | 
			
		||||
    mounts the local 'app/' directory into the container, and sets environment
 | 
			
		||||
    variables to enable Flask's development mode.
 | 
			
		||||
    """
 | 
			
		||||
    current_dir = os.getcwd()
 | 
			
		||||
    volume_mapping = f"{current_dir}/app/:/app"
 | 
			
		||||
    command = [
 | 
			
		||||
        "docker", "run", "-d",
 | 
			
		||||
        "-p", f"{PORT}:{PORT}",
 | 
			
		||||
        "--name", "portfolio",
 | 
			
		||||
        "-v", volume_mapping,
 | 
			
		||||
        "-e", "FLASK_APP=app.py",
 | 
			
		||||
        "-e", "FLASK_ENV=development",
 | 
			
		||||
        "application-portfolio"
 | 
			
		||||
    ]
 | 
			
		||||
    run_command(command, args.dry_run)
 | 
			
		||||
 | 
			
		||||
def run_prod(args):
 | 
			
		||||
    """
 | 
			
		||||
    Run the container in production mode.
 | 
			
		||||
    
 | 
			
		||||
    Command:
 | 
			
		||||
      docker run -d -p 5000:5000 --name portfolio application-portfolio
 | 
			
		||||
      
 | 
			
		||||
    This command starts the container in detached mode, mapping port 5000,
 | 
			
		||||
    and runs the production version of the portfolio application.
 | 
			
		||||
    """
 | 
			
		||||
    command = [
 | 
			
		||||
        "docker", "run", "-d",
 | 
			
		||||
        "-p", "{PORT}:5000",
 | 
			
		||||
        "--name", "portfolio",
 | 
			
		||||
        "application-portfolio"
 | 
			
		||||
    ]
 | 
			
		||||
    run_command(command, args.dry_run)
 | 
			
		||||
 | 
			
		||||
def logs(args):
 | 
			
		||||
    """
 | 
			
		||||
    Display the logs of the 'portfolio' container.
 | 
			
		||||
    
 | 
			
		||||
    Command:
 | 
			
		||||
      docker logs -f portfolio
 | 
			
		||||
      
 | 
			
		||||
    This command follows the logs (using -f) of the running container,
 | 
			
		||||
    which is helpful for debugging and monitoring.
 | 
			
		||||
    """
 | 
			
		||||
    command = ["docker", "logs", "-f", "portfolio"]
 | 
			
		||||
    run_command(command, args.dry_run)
 | 
			
		||||
 | 
			
		||||
def dev(args):
 | 
			
		||||
    """
 | 
			
		||||
    Run the application in development mode using docker-compose.
 | 
			
		||||
    """
 | 
			
		||||
    env = os.environ.copy()
 | 
			
		||||
    env["FLASK_ENV"] = "development"
 | 
			
		||||
    command = ["docker-compose", "up", "-d"]
 | 
			
		||||
    print("▶️ Starting in development mode (FLASK_ENV=development)")
 | 
			
		||||
    run_command(command, args.dry_run, env=env)
 | 
			
		||||
 | 
			
		||||
def prod(args):
 | 
			
		||||
    """
 | 
			
		||||
    Run the application in production mode using docker-compose.
 | 
			
		||||
    
 | 
			
		||||
    Command:
 | 
			
		||||
      docker-compose up --build
 | 
			
		||||
      
 | 
			
		||||
    This command builds the Docker image if needed and starts the application
 | 
			
		||||
    using docker-compose for a production environment.
 | 
			
		||||
    """
 | 
			
		||||
    command = ["docker-compose", "up", "--build"]
 | 
			
		||||
    run_command(command, args.dry_run)
 | 
			
		||||
 | 
			
		||||
def cleanup(args):
 | 
			
		||||
    """
 | 
			
		||||
    Remove all stopped Docker containers.
 | 
			
		||||
    
 | 
			
		||||
    Command:
 | 
			
		||||
      docker container prune -f
 | 
			
		||||
      
 | 
			
		||||
    This command cleans up your Docker environment by forcefully removing
 | 
			
		||||
    all stopped containers. It is useful to reclaim disk space and remove
 | 
			
		||||
    unused containers.
 | 
			
		||||
    """
 | 
			
		||||
    command = ["docker", "container", "prune", "-f"]
 | 
			
		||||
    run_command(command, args.dry_run)
 | 
			
		||||
    
 | 
			
		||||
def delete_portfolio_container(dry_run=False):
 | 
			
		||||
    """
 | 
			
		||||
    Force remove the portfolio container if it exists.
 | 
			
		||||
    """
 | 
			
		||||
    print("Checking if 'portfolio' container exists to delete...")
 | 
			
		||||
    command = ["docker", "rm", "-f", "portfolio"]
 | 
			
		||||
    run_command(command, dry_run)
 | 
			
		||||
 | 
			
		||||
def browse(args):
 | 
			
		||||
    """
 | 
			
		||||
    Open http://localhost:5000 in Chromium browser.
 | 
			
		||||
 | 
			
		||||
    Command:
 | 
			
		||||
      chromium http://localhost:5000
 | 
			
		||||
 | 
			
		||||
    This command launches the Chromium browser to view the running application.
 | 
			
		||||
    """
 | 
			
		||||
    command = ["chromium", f"http://localhost:{PORT}"]
 | 
			
		||||
    run_command(command, args.dry_run)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    parser = argparse.ArgumentParser(
 | 
			
		||||
        description="CLI tool to manage the Portfolio CMS Docker application."
 | 
			
		||||
        description="CLI proxy to Makefile targets for Portfolio CMS Docker app"
 | 
			
		||||
    )
 | 
			
		||||
    parser.add_argument(
 | 
			
		||||
        "--dry-run",
 | 
			
		||||
        action="store_true",
 | 
			
		||||
        help="Print the commands without executing them."
 | 
			
		||||
    )
 | 
			
		||||
    
 | 
			
		||||
    parser.add_argument(
 | 
			
		||||
        "--delete",
 | 
			
		||||
        action="store_true",
 | 
			
		||||
        help="Delete the existing 'portfolio' container before running the command."
 | 
			
		||||
        help="Print the generated Make command without executing it."
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    subparsers = parser.add_subparsers(
 | 
			
		||||
        title="Commands",
 | 
			
		||||
        description="Available commands to manage the application",
 | 
			
		||||
        dest="command"
 | 
			
		||||
        title="Available commands",
 | 
			
		||||
        dest="command",
 | 
			
		||||
        required=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Browse command
 | 
			
		||||
    parser_browse = subparsers.add_parser(
 | 
			
		||||
        "browse", help="Open application in Chromium browser."
 | 
			
		||||
    )
 | 
			
		||||
    parser_browse.set_defaults(func=browse)
 | 
			
		||||
 | 
			
		||||
    # Build command
 | 
			
		||||
    parser_build = subparsers.add_parser(
 | 
			
		||||
        "build", help="Build the Docker image."
 | 
			
		||||
    )
 | 
			
		||||
    parser_build.set_defaults(func=build)
 | 
			
		||||
    
 | 
			
		||||
    # Up command (docker-compose up)
 | 
			
		||||
    parser_up = subparsers.add_parser(
 | 
			
		||||
        "up", help="Start the application using docker-compose (with build)."
 | 
			
		||||
    )
 | 
			
		||||
    parser_up.set_defaults(func=up)
 | 
			
		||||
    
 | 
			
		||||
    # Down command
 | 
			
		||||
    parser_down = subparsers.add_parser(
 | 
			
		||||
        "down", help="Stop and remove the Docker container."
 | 
			
		||||
    )
 | 
			
		||||
    parser_down.set_defaults(func=down)
 | 
			
		||||
    
 | 
			
		||||
    # Run-dev command
 | 
			
		||||
    parser_run_dev = subparsers.add_parser(
 | 
			
		||||
        "run-dev", help="Run the container in development mode (with hot-reloading)."
 | 
			
		||||
    )
 | 
			
		||||
    parser_run_dev.set_defaults(func=run_dev)
 | 
			
		||||
    
 | 
			
		||||
    # Run-prod command
 | 
			
		||||
    parser_run_prod = subparsers.add_parser(
 | 
			
		||||
        "run-prod", help="Run the container in production mode."
 | 
			
		||||
    )
 | 
			
		||||
    parser_run_prod.set_defaults(func=run_prod)
 | 
			
		||||
    
 | 
			
		||||
    # Logs command
 | 
			
		||||
    parser_logs = subparsers.add_parser(
 | 
			
		||||
        "logs", help="Display the logs of the running container."
 | 
			
		||||
    )
 | 
			
		||||
    parser_logs.set_defaults(func=logs)
 | 
			
		||||
    
 | 
			
		||||
    # Dev command (docker-compose with FLASK_ENV)
 | 
			
		||||
    parser_dev = subparsers.add_parser(
 | 
			
		||||
        "dev", help="Start the application in development mode using docker-compose."
 | 
			
		||||
    )
 | 
			
		||||
    parser_dev.set_defaults(func=dev)
 | 
			
		||||
    
 | 
			
		||||
    # Prod command (docker-compose production)
 | 
			
		||||
    parser_prod = subparsers.add_parser(
 | 
			
		||||
        "prod", help="Start the application in production mode using docker-compose."
 | 
			
		||||
    )
 | 
			
		||||
    parser_prod.set_defaults(func=prod)
 | 
			
		||||
    
 | 
			
		||||
    # Cleanup command
 | 
			
		||||
    parser_cleanup = subparsers.add_parser(
 | 
			
		||||
        "cleanup", help="Remove all stopped Docker containers."
 | 
			
		||||
    )
 | 
			
		||||
    parser_cleanup.set_defaults(func=cleanup)
 | 
			
		||||
    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()
 | 
			
		||||
 | 
			
		||||
    if args.command is None:
 | 
			
		||||
    if not args.command:
 | 
			
		||||
        parser.print_help()
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    if args.delete:
 | 
			
		||||
        delete_portfolio_container(args.dry_run)
 | 
			
		||||
    cmd = ["make", args.target]
 | 
			
		||||
    run_command(cmd, dry_run=args.dry_run)
 | 
			
		||||
 | 
			
		||||
    # Execute the chosen subcommand function
 | 
			
		||||
    args.func(args)
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    from pathlib import Path
 | 
			
		||||
    main()
 | 
			
		||||
		Reference in New Issue
	
	Block a user