Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec4483c415 | |||
| 52656c715e | |||
| f6755aad42 | |||
| cbbcc09fc2 | |||
| aa8912e8b7 | |||
| 68488598af | |||
| 6e8bb8236e | |||
| bd92d60d08 | |||
| 4991587d99 | |||
| 2b8bf0b7d5 | |||
| 9ae316cdd9 | |||
| 12f62e5146 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
NUITKA_BUILD=0
|
NUITKA_BUILD=0
|
||||||
INVENTAR_HTTP_PORT=80
|
INVENTAR_HTTP_PORT=80
|
||||||
INVENTAR_HTTPS_PORT=443
|
INVENTAR_HTTPS_PORT=443
|
||||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:latest
|
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
[2026-04-19 20:20:39,502] ERROR in app: Error during scheduler shutdown: name '__file__' is not defined
|
|
||||||
@@ -15,3 +15,4 @@ python-barcode
|
|||||||
openpyxl
|
openpyxl
|
||||||
cryptography
|
cryptography
|
||||||
pywebpush
|
pywebpush
|
||||||
|
py-vapid==1.9.0
|
||||||
|
|||||||
@@ -34,6 +34,25 @@
|
|||||||
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
|
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
|
||||||
|
html {
|
||||||
|
/* Ensure viewport-fit is respected */
|
||||||
|
viewport-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* Respect safe areas on mobile devices */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Future-proof: Support for various viewport and safe-area features */
|
||||||
|
@supports (padding: max(0px)) {
|
||||||
|
body {
|
||||||
|
/* Ensure body padding never conflicts with safe areas */
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Make inputs visible in dark mode */
|
/* Make inputs visible in dark mode */
|
||||||
:root[data-theme="dark"] input,
|
:root[data-theme="dark"] input,
|
||||||
:root[data-theme="dark"] select,
|
:root[data-theme="dark"] select,
|
||||||
@@ -172,11 +191,16 @@ select:focus {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
/* Safe area insets for iPad notches and Dynamic Island */
|
||||||
|
padding-left: env(safe-area-inset-left, 0);
|
||||||
|
padding-right: env(safe-area-inset-right, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
/* Prevent navbar brand from extending into unsafe areas */
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -190,6 +214,41 @@ select:focus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile and tablet safe area handling for navbar */
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.navbar {
|
||||||
|
/* Ensure navbar spans full width but respects safe areas */
|
||||||
|
width: 100vw;
|
||||||
|
margin-left: calc(-50vw + 50%);
|
||||||
|
/* Safe area padding is already handled by navbar class */
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .container-fluid {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Ensure padding accounts for safe areas on small screens */
|
||||||
|
padding-left: max(env(safe-area-inset-left, 0), 12px);
|
||||||
|
padding-right: max(env(safe-area-inset-right, 0), 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-collapse {
|
||||||
|
/* Ensure collapsed navbar respects safe areas */
|
||||||
|
padding-left: env(safe-area-inset-left, 0);
|
||||||
|
padding-right: env(safe-area-inset-right, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iPad-specific landscape and portrait handling */
|
||||||
|
@media (max-height: 600px) and (orientation: landscape) {
|
||||||
|
.navbar {
|
||||||
|
padding-top: max(env(safe-area-inset-top, 0), 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .container-fluid {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Edit and generate buttons with theme-aware text for better visibility */
|
/* Edit and generate buttons with theme-aware text for better visibility */
|
||||||
.edit-button, .duplicate-button, .generate-qr-button {
|
.edit-button, .duplicate-button, .generate-qr-button {
|
||||||
color: var(--ui-text) !important; /* Override any existing color */
|
color: var(--ui-text) !important; /* Override any existing color */
|
||||||
|
|||||||
+192
-78
@@ -169,31 +169,87 @@
|
|||||||
background-color: var(--module-primary-color) !important;
|
background-color: var(--module-primary-color) !important;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
padding-top: env(safe-area-inset-top, 0); /* iOS Safe Area */
|
/* iOS Safe Area insets for notches and rounded corners */
|
||||||
|
padding-top: env(safe-area-inset-top, 0);
|
||||||
|
padding-left: env(safe-area-inset-left, 0);
|
||||||
|
padding-right: env(safe-area-inset-right, 0);
|
||||||
z-index: 1900;
|
z-index: 1900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .container-fluid {
|
.navbar .container-fluid {
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0); /* Fallback */
|
/* Ensure container respects safe areas on all sides */
|
||||||
|
padding-left: max(env(safe-area-inset-left, 0), 12px);
|
||||||
|
padding-right: max(env(safe-area-inset-right, 0), 12px);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.4rem;
|
font-size: 1.3rem;
|
||||||
|
padding-right: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand::before {
|
.navbar-brand::before {
|
||||||
content: attr(data-icon);
|
content: attr(data-icon);
|
||||||
margin-right: 8px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-nav .nav-link {
|
.navbar-nav .nav-link {
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.6rem 0.85rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
transition: padding .18s ease, font-size .18s ease;
|
transition: padding .18s ease, font-size .18s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iPad/Tablet responsive adjustments */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.navbar .container-fluid {
|
||||||
|
gap: 6px;
|
||||||
|
padding-left: max(env(safe-area-inset-left, 0), 10px);
|
||||||
|
padding-right: max(env(safe-area-inset-right, 0), 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand::before {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small phones responsive adjustments */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.navbar .container-fluid {
|
||||||
|
gap: 4px;
|
||||||
|
padding-left: max(env(safe-area-inset-left, 0), 8px);
|
||||||
|
padding-right: max(env(safe-area-inset-right, 0), 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar.nav-compact .navbar-brand {
|
.navbar.nav-compact .navbar-brand {
|
||||||
@@ -250,6 +306,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
width: min(420px, 42vw);
|
width: min(420px, 42vw);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-search-form {
|
.function-search-form {
|
||||||
@@ -257,10 +314,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-search-input {
|
.function-search-input {
|
||||||
width: 100%;
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
@@ -281,6 +341,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.function-search-btn {
|
.function-search-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
@@ -483,9 +544,48 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-collapse {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-collapse > .d-flex {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.function-search-wrap {
|
.function-search-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
margin: 8px 0 10px;
|
margin: 8px 0 10px;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-search-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-search-input {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-search-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-text {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-wrap {
|
||||||
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-nav .nav-item {
|
.navbar-nav .nav-item {
|
||||||
@@ -857,12 +957,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'username' in session %}
|
{% if 'username' in session %}
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||||
<li class="nav-item" data-nav-fixed="true">
|
<li class="nav-item">
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
|
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||||
<li class="nav-item" data-nav-fixed="true">
|
<li class="nav-item">
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -872,9 +972,7 @@
|
|||||||
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li class="nav-item" data-nav-fixed="true">
|
||||||
|
|
||||||
<li class="nav-item">
|
|
||||||
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||||
<span class="theme-icon-light" style="display: none;">☀️</span>
|
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||||
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||||
@@ -886,15 +984,6 @@
|
|||||||
Mehr Optionen
|
Mehr Optionen
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
||||||
{% if 'username' in session %}
|
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
|
||||||
{% endif %}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||||
<li><h6 class="dropdown-header">Verwaltung</h6></li>
|
<li><h6 class="dropdown-header">Verwaltung</h6></li>
|
||||||
{% if current_permissions.pages.get('manage_filters', True) %}
|
{% if current_permissions.pages.get('manage_filters', True) %}
|
||||||
@@ -987,12 +1076,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'username' in session %}
|
{% if 'username' in session %}
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||||
<li class="nav-item" data-nav-fixed="true">
|
<li class="nav-item">
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
|
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||||
<li class="nav-item" data-nav-fixed="true">
|
<li class="nav-item">
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -1002,9 +1091,7 @@
|
|||||||
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li class="nav-item" data-nav-fixed="true">
|
||||||
|
|
||||||
<li class="nav-item">
|
|
||||||
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||||
<span class="theme-icon-light" style="display: none;">☀️</span>
|
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||||
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||||
@@ -1016,15 +1103,6 @@
|
|||||||
Mehr Optionen
|
Mehr Optionen
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
||||||
{% if 'username' in session %}
|
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
|
||||||
{% endif %}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||||
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
||||||
@@ -1602,8 +1680,15 @@
|
|||||||
const libraryNavList = document.getElementById('libraryNavList');
|
const libraryNavList = document.getElementById('libraryNavList');
|
||||||
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
|
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
|
||||||
|
|
||||||
function initNavbarOverflow(navList, navOverflowAnchor) {
|
function initNavbarOverflow(navList, navOverflowAnchor, options) {
|
||||||
if (!navList || !navOverflowAnchor) return;
|
if (!navList) return;
|
||||||
|
|
||||||
|
const moreToggleId = options && options.moreToggleId ? options.moreToggleId : '';
|
||||||
|
const moreToggle = moreToggleId ? document.getElementById(moreToggleId) : null;
|
||||||
|
const moreControlItem = moreToggle ? moreToggle.closest('li.nav-item.dropdown') : null;
|
||||||
|
const moreMenu = moreControlItem ? moreControlItem.querySelector(':scope > ul.dropdown-menu') : null;
|
||||||
|
const moreLabelDefault = moreToggle ? moreToggle.textContent.trim() : 'Mehr Optionen';
|
||||||
|
const moreMenuOriginal = moreMenu ? moreMenu.innerHTML : '';
|
||||||
|
|
||||||
const navRoot = navList.closest('nav.navbar');
|
const navRoot = navList.closest('nav.navbar');
|
||||||
const navCollapse = navList.closest('.navbar-collapse');
|
const navCollapse = navList.closest('.navbar-collapse');
|
||||||
@@ -1631,45 +1716,35 @@
|
|||||||
return Array.from(navList.children).filter(function(li){
|
return Array.from(navList.children).filter(function(li){
|
||||||
if (!(li instanceof HTMLElement)) return false;
|
if (!(li instanceof HTMLElement)) return false;
|
||||||
if (!li.classList.contains('nav-item')) return false;
|
if (!li.classList.contains('nav-item')) return false;
|
||||||
if (li.id === navOverflowAnchor.id) return false;
|
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return false;
|
||||||
if (li.dataset.overflowControl === 'true') return false;
|
if (li.dataset.overflowControl === 'true') return false;
|
||||||
if (li.dataset.navFixed === 'true') return false;
|
if (li.dataset.navFixed === 'true') return false;
|
||||||
|
if (moreControlItem && li === moreControlItem) return false;
|
||||||
return li.style.display !== 'none';
|
return li.style.display !== 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearOverflowControls() {
|
function clearOverflowControls() {
|
||||||
if (!navList) return;
|
if (!moreMenu) return;
|
||||||
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){
|
moreMenu.innerHTML = moreMenuOriginal;
|
||||||
node.remove();
|
if (moreControlItem) {
|
||||||
});
|
moreControlItem.style.display = '';
|
||||||
|
}
|
||||||
|
if (moreToggle) {
|
||||||
|
moreToggle.textContent = moreLabelDefault;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreAllNavItems() {
|
function restoreAllNavItems() {
|
||||||
if (!navList) return;
|
if (!navList) return;
|
||||||
navList.querySelectorAll('li.nav-item').forEach(function(li){
|
navList.querySelectorAll('li.nav-item').forEach(function(li){
|
||||||
if (li.id === navOverflowAnchor.id) return;
|
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return;
|
||||||
if (li.dataset.overflowControl === 'true') return;
|
if (li.dataset.overflowControl === 'true') return;
|
||||||
li.style.display = '';
|
li.style.display = '';
|
||||||
});
|
});
|
||||||
clearOverflowControls();
|
clearOverflowControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOverflowControl() {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'nav-item dropdown';
|
|
||||||
li.dataset.overflowControl = 'true';
|
|
||||||
|
|
||||||
const toggleId = navOverflowAnchor.id + '-toggle';
|
|
||||||
const menuId = navOverflowAnchor.id + '-menu';
|
|
||||||
|
|
||||||
li.innerHTML =
|
|
||||||
'<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
|
|
||||||
'<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
|
|
||||||
|
|
||||||
return li;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNavCandidatePriority(item) {
|
function getNavCandidatePriority(item) {
|
||||||
if (!(item instanceof HTMLElement)) {
|
if (!(item instanceof HTMLElement)) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -1752,33 +1827,70 @@
|
|||||||
|
|
||||||
function rebuildOverflowControl(hiddenSources) {
|
function rebuildOverflowControl(hiddenSources) {
|
||||||
clearOverflowControls();
|
clearOverflowControls();
|
||||||
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return;
|
if (!moreMenu || hiddenSources.length === 0) return;
|
||||||
|
|
||||||
const control = createOverflowControl();
|
|
||||||
const menu = control.querySelector('ul.dropdown-menu');
|
|
||||||
const controlLink = control.querySelector(':scope > a.nav-link');
|
|
||||||
if (controlLink) {
|
|
||||||
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const overflowFragment = document.createDocumentFragment();
|
||||||
hiddenSources.forEach(function(source){
|
hiddenSources.forEach(function(source){
|
||||||
appendSourceToOverflowMenu(source, menu);
|
appendSourceToOverflowMenu(source, overflowFragment);
|
||||||
});
|
});
|
||||||
|
|
||||||
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider');
|
const overflowWrap = document.createElement('div');
|
||||||
if (trailingDivider) {
|
overflowWrap.appendChild(overflowFragment);
|
||||||
|
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
|
||||||
|
if (trailingDivider && trailingDivider.parentElement) {
|
||||||
trailingDivider.parentElement.remove();
|
trailingDivider.parentElement.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
navList.insertBefore(control, navOverflowAnchor);
|
const overflowEntries = Array.from(overflowWrap.childNodes);
|
||||||
|
if (overflowEntries.length > 0) {
|
||||||
|
const marker = document.createElement('li');
|
||||||
|
marker.innerHTML = '<h6 class="dropdown-header">Ausgeblendete Navigation</h6>';
|
||||||
|
const firstExistingNode = moreMenu.firstChild;
|
||||||
|
moreMenu.insertBefore(marker, firstExistingNode);
|
||||||
|
|
||||||
|
let insertAfter = marker;
|
||||||
|
overflowEntries.forEach(function(node){
|
||||||
|
moreMenu.insertBefore(node, insertAfter.nextSibling);
|
||||||
|
insertAfter = node;
|
||||||
|
});
|
||||||
|
|
||||||
|
const divider = document.createElement('li');
|
||||||
|
divider.innerHTML = '<hr class="dropdown-divider">';
|
||||||
|
moreMenu.insertBefore(divider, insertAfter.nextSibling);
|
||||||
|
|
||||||
|
if (moreToggle) {
|
||||||
|
moreToggle.textContent = moreLabelDefault + ' (' + hiddenSources.length + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moreControlItem) {
|
||||||
|
moreControlItem.style.display = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adaptNavbarByWidth() {
|
function adaptNavbarByWidth() {
|
||||||
if (!navList || !navOverflowAnchor) return;
|
if (!navList) return;
|
||||||
|
|
||||||
|
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
|
||||||
|
|
||||||
|
function isNavOverflowing(bufferPx) {
|
||||||
|
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
|
||||||
|
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
|
||||||
|
const containerOverflow = navContainer ? (navContainer.scrollWidth > (navContainer.clientWidth - buffer)) : false;
|
||||||
|
const listOverflow = navList.scrollWidth > (navList.clientWidth - buffer);
|
||||||
|
return collapseOverflow || containerOverflow || listOverflow;
|
||||||
|
}
|
||||||
|
|
||||||
applyCompactMode();
|
applyCompactMode();
|
||||||
|
|
||||||
if (window.innerWidth < 992) {
|
// Desktop/tablet-large: keep complete navigation visible.
|
||||||
|
if (!isMobileViewport) {
|
||||||
|
restoreAllNavItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile menu is collapsed: avoid hiding links preemptively.
|
||||||
|
if (navCollapse && !navCollapse.classList.contains('show')) {
|
||||||
restoreAllNavItems();
|
restoreAllNavItems();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1787,8 +1899,9 @@
|
|||||||
|
|
||||||
const hiddenSources = [];
|
const hiddenSources = [];
|
||||||
let candidates = collectTopLevelNavSources();
|
let candidates = collectTopLevelNavSources();
|
||||||
|
const overflowBuffer = 12;
|
||||||
|
|
||||||
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
|
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||||
const toHide = pickNextNavItemToHide(candidates);
|
const toHide = pickNextNavItemToHide(candidates);
|
||||||
if (!toHide) {
|
if (!toHide) {
|
||||||
break;
|
break;
|
||||||
@@ -1800,8 +1913,9 @@
|
|||||||
|
|
||||||
rebuildOverflowControl(hiddenSources);
|
rebuildOverflowControl(hiddenSources);
|
||||||
|
|
||||||
|
// One final pass in case the overflow menu label/count itself changed row width.
|
||||||
candidates = collectTopLevelNavSources();
|
candidates = collectTopLevelNavSources();
|
||||||
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
|
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||||
const toHide = pickNextNavItemToHide(candidates);
|
const toHide = pickNextNavItemToHide(candidates);
|
||||||
if (!toHide) {
|
if (!toHide) {
|
||||||
break;
|
break;
|
||||||
@@ -1839,12 +1953,12 @@
|
|||||||
|
|
||||||
// Initialize overflow control for inventory navbar
|
// Initialize overflow control for inventory navbar
|
||||||
if (navList && navOverflowAnchor) {
|
if (navList && navOverflowAnchor) {
|
||||||
initNavbarOverflow(navList, navOverflowAnchor);
|
initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize overflow control for library navbar
|
// Initialize overflow control for library navbar
|
||||||
if (libraryNavList && libNavOverflowAnchor) {
|
if (libraryNavList && libNavOverflowAnchor) {
|
||||||
initNavbarOverflow(libraryNavList, libNavOverflowAnchor);
|
initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+237
-1
@@ -208,12 +208,23 @@
|
|||||||
<script>
|
<script>
|
||||||
// View mode persistence
|
// View mode persistence
|
||||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||||
|
const MOBILE_LIBRARY_MAX_WIDTH = 900;
|
||||||
|
const MOBILE_LIBRARY_MIN_ITEMS = 24;
|
||||||
|
|
||||||
|
function isMobileViewport() {
|
||||||
|
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
|
||||||
|
}
|
||||||
|
|
||||||
function setViewMode(mode) {
|
function setViewMode(mode) {
|
||||||
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
|
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
|
||||||
const container = document.getElementById('itemsContainer');
|
const container = document.getElementById('itemsContainer');
|
||||||
const tableHeader = document.getElementById('tableHeader');
|
const tableHeader = document.getElementById('tableHeader');
|
||||||
const items = container.querySelectorAll('.item-card');
|
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
|
||||||
|
const virtualizationState = window.mobileLibraryVirtualizationState || null;
|
||||||
|
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
|
||||||
|
? virtualizationState.items.filter(item => !container.contains(item))
|
||||||
|
: [];
|
||||||
|
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const cardContent = item.querySelector('.item-content.card-mode');
|
const cardContent = item.querySelector('.item-content.card-mode');
|
||||||
@@ -236,6 +247,229 @@ function setViewMode(mode) {
|
|||||||
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
|
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initMobileWindowedLibraryLoading() {
|
||||||
|
const container = document.getElementById('itemsContainer');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.mobileLibraryVirtualizationState) {
|
||||||
|
window.mobileLibraryVirtualizationState = {
|
||||||
|
active: false,
|
||||||
|
items: [],
|
||||||
|
topSpacer: null,
|
||||||
|
bottomSpacer: null,
|
||||||
|
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
|
||||||
|
lastStart: -1,
|
||||||
|
lastEnd: -1,
|
||||||
|
framePending: false,
|
||||||
|
scrollListener: null,
|
||||||
|
resizeListener: null,
|
||||||
|
initialized: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const state = window.mobileLibraryVirtualizationState;
|
||||||
|
|
||||||
|
function restoreAllImages() {
|
||||||
|
state.items.forEach(item => {
|
||||||
|
const image = item.querySelector('img.item-image');
|
||||||
|
if (!image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSrc = image.dataset.mobileSrc || '';
|
||||||
|
if (!image.getAttribute('src') && originalSrc) {
|
||||||
|
image.setAttribute('src', originalSrc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImageMemory(startIndex, endIndex) {
|
||||||
|
const imageBuffer = 4;
|
||||||
|
state.items.forEach((item, index) => {
|
||||||
|
const image = item.querySelector('img.item-image');
|
||||||
|
if (!image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image.dataset.mobileSrc) {
|
||||||
|
image.dataset.mobileSrc = image.getAttribute('src') || '';
|
||||||
|
}
|
||||||
|
image.loading = 'lazy';
|
||||||
|
image.decoding = 'async';
|
||||||
|
|
||||||
|
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
|
||||||
|
if (shouldKeepLoaded) {
|
||||||
|
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
|
||||||
|
image.setAttribute('src', image.dataset.mobileSrc);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.getAttribute('src')) {
|
||||||
|
image.removeAttribute('src');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVisibleWindow() {
|
||||||
|
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportHeight = window.innerHeight || 800;
|
||||||
|
const scrollTop = window.scrollY || window.pageYOffset || 0;
|
||||||
|
const renderBuffer = viewportHeight * 1.5;
|
||||||
|
|
||||||
|
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
|
||||||
|
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
|
||||||
|
|
||||||
|
if (endIndex - startIndex < 14) {
|
||||||
|
endIndex = Math.min(state.items.length, startIndex + 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastStart = startIndex;
|
||||||
|
state.lastEnd = endIndex;
|
||||||
|
|
||||||
|
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
|
||||||
|
state.topSpacer.nextSibling.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let index = startIndex; index < endIndex; index += 1) {
|
||||||
|
fragment.appendChild(state.items[index]);
|
||||||
|
}
|
||||||
|
container.insertBefore(fragment, state.bottomSpacer);
|
||||||
|
|
||||||
|
let measuredHeightSum = 0;
|
||||||
|
let measuredCount = 0;
|
||||||
|
for (let index = startIndex; index < endIndex; index += 1) {
|
||||||
|
const height = state.items[index].getBoundingClientRect().height;
|
||||||
|
if (height > 0) {
|
||||||
|
measuredHeightSum += height;
|
||||||
|
measuredCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (measuredCount > 0) {
|
||||||
|
const measuredAverage = measuredHeightSum / measuredCount;
|
||||||
|
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
|
||||||
|
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
|
||||||
|
updateImageMemory(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWindowRender() {
|
||||||
|
if (!state.active || state.framePending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.framePending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
state.framePending = false;
|
||||||
|
renderVisibleWindow();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateVirtualization() {
|
||||||
|
if (state.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||||
|
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||||
|
}
|
||||||
|
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.topSpacer = document.createElement('div');
|
||||||
|
state.topSpacer.className = 'mobile-window-spacer top';
|
||||||
|
state.bottomSpacer = document.createElement('div');
|
||||||
|
state.bottomSpacer.className = 'mobile-window-spacer bottom';
|
||||||
|
|
||||||
|
state.items.forEach(item => item.remove());
|
||||||
|
container.appendChild(state.topSpacer);
|
||||||
|
container.appendChild(state.bottomSpacer);
|
||||||
|
|
||||||
|
state.lastStart = -1;
|
||||||
|
state.lastEnd = -1;
|
||||||
|
state.framePending = false;
|
||||||
|
state.active = true;
|
||||||
|
|
||||||
|
if (!state.scrollListener) {
|
||||||
|
state.scrollListener = () => scheduleWindowRender();
|
||||||
|
window.addEventListener('scroll', state.scrollListener, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleWindowRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivateVirtualization() {
|
||||||
|
if (!state.active) {
|
||||||
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||||
|
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.scrollListener) {
|
||||||
|
window.removeEventListener('scroll', state.scrollListener);
|
||||||
|
state.scrollListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.topSpacer && state.topSpacer.parentNode === container) {
|
||||||
|
state.topSpacer.remove();
|
||||||
|
}
|
||||||
|
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
|
||||||
|
state.bottomSpacer.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
state.items.forEach(item => fragment.appendChild(item));
|
||||||
|
container.appendChild(fragment);
|
||||||
|
|
||||||
|
restoreAllImages();
|
||||||
|
|
||||||
|
state.topSpacer = null;
|
||||||
|
state.bottomSpacer = null;
|
||||||
|
state.lastStart = -1;
|
||||||
|
state.lastEnd = -1;
|
||||||
|
state.framePending = false;
|
||||||
|
state.active = false;
|
||||||
|
|
||||||
|
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||||
|
setViewMode(currentMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVirtualizationWithViewport() {
|
||||||
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||||
|
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
|
||||||
|
if (shouldVirtualize) {
|
||||||
|
activateVirtualization();
|
||||||
|
scheduleWindowRender();
|
||||||
|
} else {
|
||||||
|
deactivateVirtualization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.initialized) {
|
||||||
|
state.resizeListener = () => syncVirtualizationWithViewport();
|
||||||
|
window.addEventListener('resize', state.resizeListener, { passive: true });
|
||||||
|
state.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncVirtualizationWithViewport();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize view mode
|
// Initialize view mode
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||||
@@ -311,6 +545,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (e.target === this) this.style.display = 'none';
|
if (e.target === this) this.style.display = 'none';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
initMobileWindowedLibraryLoading();
|
||||||
});
|
});
|
||||||
|
|
||||||
function displayLibraryItemDetail(item) {
|
function displayLibraryItemDetail(item) {
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
|
|||||||
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
|
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
|
||||||
KEEP_DAYS="${KEEP_DAYS:-7}"
|
KEEP_DAYS="${KEEP_DAYS:-7}"
|
||||||
MIN_KEEP="${MIN_KEEP:-7}"
|
MIN_KEEP="${MIN_KEEP:-7}"
|
||||||
DB_NAME="${DB_NAME:-Inventarsystem}"
|
DB_NAME="${DB_NAME:-inventar_default}"
|
||||||
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
|
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
|
||||||
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
||||||
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
|
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
|
||||||
BACKUP_MODE="${BACKUP_MODE:-auto}"
|
BACKUP_MODE="${BACKUP_MODE:-auto}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
|
||||||
DOCKER_AVAILABLE=0
|
DOCKER_AVAILABLE=0
|
||||||
DOCKER_COMPOSE_CMD=()
|
DOCKER_COMPOSE_CMD=()
|
||||||
USE_NULL_OUTPUT=false
|
USE_NULL_OUTPUT=false
|
||||||
@@ -58,6 +59,8 @@ Options:
|
|||||||
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
|
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
|
||||||
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
|
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
|
||||||
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
|
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
-h|--help Show this help and exit
|
-h|--help Show this help and exit
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MIN_KEEP="$2"; shift 2;;
|
MIN_KEEP="$2"; shift 2;;
|
||||||
--mode)
|
--mode)
|
||||||
BACKUP_MODE="$2"; shift 2;;
|
BACKUP_MODE="$2"; shift 2;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"; shift;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage; exit 0;;
|
usage; exit 0;;
|
||||||
*)
|
*)
|
||||||
@@ -102,12 +109,12 @@ log_message() {
|
|||||||
init_docker_compose() {
|
init_docker_compose() {
|
||||||
if docker compose version >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1; then
|
||||||
DOCKER_AVAILABLE=1
|
DOCKER_AVAILABLE=1
|
||||||
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if sudo docker compose version >/dev/null 2>&1; then
|
if sudo docker compose version >/dev/null 2>&1; then
|
||||||
DOCKER_AVAILABLE=1
|
DOCKER_AVAILABLE=1
|
||||||
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,10 @@
|
|||||||
# Scale example: To support 10 tenants with 20 users each:
|
# Scale example: To support 10 tenants with 20 users each:
|
||||||
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
|
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
|
||||||
|
|
||||||
version: '3.9'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Management Container for multi-tenant scripts
|
# Management Container for multi-tenant scripts
|
||||||
tenant-manager:
|
tenant-manager:
|
||||||
image: bash
|
image: docker:cli
|
||||||
container_name: tenant-manager
|
container_name: tenant-manager
|
||||||
restart: "no"
|
restart: "no"
|
||||||
profiles:
|
profiles:
|
||||||
@@ -21,7 +19,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- .:/workspace
|
- .:/workspace
|
||||||
entrypoint: ["bash", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
|
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.27-alpine
|
image: nginx:1.27-alpine
|
||||||
@@ -71,9 +69,9 @@ services:
|
|||||||
image: mongo:7.0
|
image: mongo:7.0
|
||||||
container_name: inventarsystem-mongodb
|
container_name: inventarsystem-mongodb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: mongod --wiredTigerCacheSizeGB 2 --journal --dbPath /data/db
|
command: mongod --wiredTigerCacheSizeGB 2
|
||||||
ports:
|
expose:
|
||||||
- "27017:27017"
|
- "27017"
|
||||||
volumes:
|
volumes:
|
||||||
- mongodb_data:/data/db
|
- mongodb_data:/data/db
|
||||||
- ./docker/mongo:/docker-entrypoint-initdb.d:ro
|
- ./docker/mongo:/docker-entrypoint-initdb.d:ro
|
||||||
@@ -193,9 +191,6 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
inventar-net:
|
inventar-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 172.20.0.0/16
|
|
||||||
|
|
||||||
# Scale guidance:
|
# Scale guidance:
|
||||||
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
|
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ upstream inventar_backend {
|
|||||||
|
|
||||||
# Main HTTPS server block
|
# Main HTTPS server block
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
|
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
|
||||||
|
|
||||||
# SSL Configuration (wildcard certificate)
|
# SSL Configuration (wildcard certificate)
|
||||||
@@ -75,9 +76,9 @@ server {
|
|||||||
application/x-javascript application/xml+rss application/json;
|
application/x-javascript application/xml+rss application/json;
|
||||||
gzip_comp_level 5;
|
gzip_comp_level 5;
|
||||||
|
|
||||||
# Access/Error logs with tenant in filename
|
# Access/Error logs
|
||||||
access_log /var/log/nginx/inventar_access_${tenant}.log combined buffer=32k;
|
access_log /var/log/nginx/inventar_access.log combined buffer=32k;
|
||||||
error_log /var/log/nginx/inventar_error_${tenant}.log warn;
|
error_log /var/log/nginx/inventar_error.log warn;
|
||||||
|
|
||||||
# Root location - proxy to app with tenant context
|
# Root location - proxy to app with tenant context
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
+24
-5
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
|
|||||||
CLEANUP_OLD_SERVICES=true
|
CLEANUP_OLD_SERVICES=true
|
||||||
CLEANUP_OLD_REMOVE_CRON=false
|
CLEANUP_OLD_REMOVE_CRON=false
|
||||||
TMP_DIR=""
|
TMP_DIR=""
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
cleanup_tmp_dir() {
|
cleanup_tmp_dir() {
|
||||||
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
||||||
@@ -50,11 +51,10 @@ refresh_start_script_from_main() {
|
|||||||
pin_compose_app_image() {
|
pin_compose_app_image() {
|
||||||
local tag="$1"
|
local tag="$1"
|
||||||
local compose_file
|
local compose_file
|
||||||
compose_file="$PROJECT_DIR/docker-compose.yml"
|
|
||||||
|
|
||||||
|
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
|
||||||
if [ ! -f "$compose_file" ]; then
|
if [ ! -f "$compose_file" ]; then
|
||||||
echo "Warning: $compose_file not found; cannot pin app image"
|
continue
|
||||||
return 0
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 - <<'PY' "$compose_file" "$tag"
|
python3 - <<'PY' "$compose_file" "$tag"
|
||||||
@@ -151,6 +151,8 @@ if in_app_service and build_found and app_service_indent is not None:
|
|||||||
with open(compose_file, "w", encoding="utf-8") as f:
|
with open(compose_file, "w", encoding="utf-8") as f:
|
||||||
f.writelines(out)
|
f.writelines(out)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
install_docker_if_missing() {
|
install_docker_if_missing() {
|
||||||
@@ -173,6 +175,8 @@ Options:
|
|||||||
--remove-legacy-system Remove old host MongoDB/system after successful import
|
--remove-legacy-system Remove old host MongoDB/system after successful import
|
||||||
--skip-cleanup-old Do not run cleanup-old.sh after install
|
--skip-cleanup-old Do not run cleanup-old.sh after install
|
||||||
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
||||||
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
||||||
--legacy-system-dir <path> Optional old system directory to remove after migration
|
--legacy-system-dir <path> Optional old system directory to remove after migration
|
||||||
@@ -199,6 +203,14 @@ parse_args() {
|
|||||||
CLEANUP_OLD_REMOVE_CRON=true
|
CLEANUP_OLD_REMOVE_CRON=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--legacy-db-name)
|
--legacy-db-name)
|
||||||
LEGACY_DB_NAME="$2"
|
LEGACY_DB_NAME="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -280,14 +292,21 @@ backup_legacy_database() {
|
|||||||
|
|
||||||
restore_legacy_backup_into_docker() {
|
restore_legacy_backup_into_docker() {
|
||||||
local i
|
local i
|
||||||
|
local compose_path
|
||||||
|
|
||||||
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
|
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||||
|
if [ ! -f "$compose_path" ]; then
|
||||||
|
echo "Error: compose file not found: $compose_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Waiting for Docker MongoDB to become ready..."
|
echo "Waiting for Docker MongoDB to become ready..."
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
if sudo docker compose -f "$compose_path" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Importing legacy backup into Docker MongoDB..."
|
echo "Importing legacy backup into Docker MongoDB..."
|
||||||
sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
sudo docker compose -f "$compose_path" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
||||||
echo "Legacy DB import completed."
|
echo "Legacy DB import completed."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/bin/sh
|
||||||
# Script to manage multitenant deployment
|
# Script to manage multitenant deployment
|
||||||
# Allows adding, removing, and restarting tenants without downtime for others
|
# Allows adding, removing, and restarting tenants without downtime for others
|
||||||
|
|
||||||
@@ -48,11 +48,12 @@ case "$COMMAND" in
|
|||||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
if [ -n "$APP_CONTAINER" ]; then
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
docker exec $APP_CONTAINER python3 -c "
|
docker exec $APP_CONTAINER python3 -c "
|
||||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
|
||||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||||
db.users.insert_one({'username': 'admin', 'password': 'hashed_password_here', 'role': 'admin'})
|
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
|
||||||
print(f'Tenant {sys.argv[1]} database initialized.')
|
db.users.insert_one({'Username': 'admin', 'Password': hashed_pw, 'Role': 'admin', 'Name': 'Admin', 'Nachname': 'User'})
|
||||||
|
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
|
||||||
" "$TENANT_ID"
|
" "$TENANT_ID"
|
||||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ python-barcode
|
|||||||
openpyxl
|
openpyxl
|
||||||
cryptography
|
cryptography
|
||||||
pywebpush
|
pywebpush
|
||||||
|
py-vapid==1.9.0
|
||||||
|
|||||||
+2
-2
@@ -3,5 +3,5 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||||
|
|
||||||
"$SCRIPT_DIR/stop.sh"
|
"$SCRIPT_DIR/stop.sh" "$@"
|
||||||
"$SCRIPT_DIR/start.sh"
|
"$SCRIPT_DIR/start.sh" "$@"
|
||||||
+14
-3
@@ -6,12 +6,13 @@ LOG_DIR="$SCRIPT_DIR/logs"
|
|||||||
LOG_FILE="$LOG_DIR/restore.log"
|
LOG_FILE="$LOG_DIR/restore.log"
|
||||||
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
|
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
|
||||||
|
|
||||||
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}"
|
DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
|
||||||
SOURCE_PATH=""
|
SOURCE_PATH=""
|
||||||
BACKUP_DATE=""
|
BACKUP_DATE=""
|
||||||
DROP_DATABASE=false
|
DROP_DATABASE=false
|
||||||
RESTART_SERVICES=false
|
RESTART_SERVICES=false
|
||||||
STAGED_PATH=""
|
STAGED_PATH=""
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
|
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
|
||||||
BACKUP_ROOT_SYSTEM="/var/backups"
|
BACKUP_ROOT_SYSTEM="/var/backups"
|
||||||
@@ -56,6 +57,8 @@ Options:
|
|||||||
- latest: newest from local/system backup roots
|
- latest: newest from local/system backup roots
|
||||||
--drop-database Drop target DB before import (recommended for full restore)
|
--drop-database Drop target DB before import (recommended for full restore)
|
||||||
--restart-services Restart stack after restore
|
--restart-services Restart stack after restore
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
--list List detected backup candidates
|
--list List detected backup candidates
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
@@ -68,12 +71,12 @@ EOF
|
|||||||
|
|
||||||
setup_compose() {
|
setup_compose() {
|
||||||
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||||
DOCKER_COMPOSE=(docker compose)
|
DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
|
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
|
||||||
DOCKER_COMPOSE=($SUDO docker compose)
|
DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -459,6 +462,14 @@ parse_args() {
|
|||||||
RESTART_SERVICES=true
|
RESTART_SERVICES=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--list)
|
--list)
|
||||||
list_backups
|
list_backups
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ cd "$SCRIPT_DIR"
|
|||||||
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||||
DIST_DIR="$SCRIPT_DIR/dist"
|
DIST_DIR="$SCRIPT_DIR/dist"
|
||||||
|
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||||
|
|
||||||
SUDO=""
|
SUDO=""
|
||||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||||
@@ -14,10 +15,11 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
NUITKA_BUILD_VALUE="0"
|
NUITKA_BUILD_VALUE="0"
|
||||||
HTTP_PORT_VALUE="80"
|
HTTP_PORT_VALUE="8001"
|
||||||
HTTPS_PORT_VALUE="443"
|
HTTPS_PORT_VALUE="8443"
|
||||||
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
|
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
|
||||||
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
|
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -26,6 +28,8 @@ Usage: $0 [options]
|
|||||||
Options:
|
Options:
|
||||||
--no-cron Do not create or update cron jobs
|
--no-cron Do not create or update cron jobs
|
||||||
--with-cron Create/update cron jobs (default)
|
--with-cron Create/update cron jobs (default)
|
||||||
|
--multitenant Use the multi-tenant architecture deployment
|
||||||
|
--singletenant Use the legacy single-tenant compose deployment
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -41,6 +45,14 @@ parse_args() {
|
|||||||
CRON_SETUP_VALUE="1"
|
CRON_SETUP_VALUE="1"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
@@ -450,7 +462,7 @@ stack_owns_host_port() {
|
|||||||
local container_port="$2"
|
local container_port="$2"
|
||||||
local mapped_port
|
local mapped_port
|
||||||
|
|
||||||
mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
|
mapped_port="$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
|
||||||
if [ -z "$mapped_port" ]; then
|
if [ -z "$mapped_port" ]; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -493,20 +505,20 @@ configure_host_ports() {
|
|||||||
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||||
fi
|
fi
|
||||||
if [ -z "$requested_http" ]; then
|
if [ -z "$requested_http" ]; then
|
||||||
requested_http="80"
|
requested_http="8001"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if stack_owns_host_port "$requested_http" "80"; then
|
if stack_owns_host_port "$requested_http" "80"; then
|
||||||
HTTP_PORT_VALUE="$requested_http"
|
HTTP_PORT_VALUE="$requested_http"
|
||||||
elif port_in_use "$requested_http"; then
|
elif port_in_use "$requested_http"; then
|
||||||
stop_host_nginx_services || true
|
|
||||||
|
|
||||||
if ! port_in_use "$requested_http"; then
|
if ! port_in_use "$requested_http"; then
|
||||||
HTTP_PORT_VALUE="$requested_http"
|
HTTP_PORT_VALUE="$requested_http"
|
||||||
echo "Freed HTTP port $requested_http by stopping host nginx service"
|
echo "Freed HTTP port $requested_http by stopping host nginx service"
|
||||||
else
|
else
|
||||||
HTTP_PORT_VALUE="$(find_free_port 8080)"
|
HTTP_PORT_VALUE="$(find_free_port 8080)"
|
||||||
echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
|
echo "HTTP port is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
HTTP_PORT_VALUE="$requested_http"
|
HTTP_PORT_VALUE="$requested_http"
|
||||||
@@ -518,13 +530,13 @@ configure_host_ports() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$requested_https" ]; then
|
if [ -z "$requested_https" ]; then
|
||||||
requested_https="443"
|
requested_https="8443"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if stack_owns_host_port "$requested_https" "443"; then
|
if stack_owns_host_port "$requested_https" "443"; then
|
||||||
HTTPS_PORT_VALUE="$requested_https"
|
HTTPS_PORT_VALUE="$requested_https"
|
||||||
elif port_in_use "$requested_https"; then
|
elif port_in_use "$requested_https"; then
|
||||||
stop_host_nginx_services || true
|
|
||||||
|
|
||||||
if ! port_in_use "$requested_https"; then
|
if ! port_in_use "$requested_https"; then
|
||||||
HTTPS_PORT_VALUE="$requested_https"
|
HTTPS_PORT_VALUE="$requested_https"
|
||||||
@@ -533,7 +545,7 @@ configure_host_ports() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
HTTPS_PORT_VALUE="$(find_free_port 8443)"
|
HTTPS_PORT_VALUE="$(find_free_port 8443)"
|
||||||
echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
|
echo "HTTPS port is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
|
||||||
else
|
else
|
||||||
HTTPS_PORT_VALUE="$requested_https"
|
HTTPS_PORT_VALUE="$requested_https"
|
||||||
fi
|
fi
|
||||||
@@ -548,9 +560,22 @@ INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
write_runtime_compose_override() {
|
||||||
|
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${APP_IMAGE_VALUE}
|
||||||
|
build: null
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
verify_stack_health() {
|
verify_stack_health() {
|
||||||
local compose_args running_services retry_count=0
|
local compose_args running_services retry_count=0
|
||||||
compose_args=(--env-file "$ENV_FILE")
|
compose_args=(-f "$COMPOSE_FILE")
|
||||||
|
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||||
|
compose_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||||
|
fi
|
||||||
|
compose_args+=(--env-file "$ENV_FILE")
|
||||||
|
|
||||||
# Try health check with optional restart on first failure
|
# Try health check with optional restart on first failure
|
||||||
while [[ $retry_count -lt 2 ]]; do
|
while [[ $retry_count -lt 2 ]]; do
|
||||||
@@ -603,9 +628,15 @@ resolve_app_image
|
|||||||
configure_host_ports
|
configure_host_ports
|
||||||
ensure_app_image_loaded
|
ensure_app_image_loaded
|
||||||
write_env_file
|
write_env_file
|
||||||
|
write_runtime_compose_override
|
||||||
|
|
||||||
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
|
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
|
||||||
docker compose --env-file "$ENV_FILE" up -d --remove-orphans
|
compose_up_args=(-f "$COMPOSE_FILE")
|
||||||
|
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||||
|
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||||
|
fi
|
||||||
|
compose_up_args+=(--env-file "$ENV_FILE")
|
||||||
|
docker compose "${compose_up_args[@]}" up -d --remove-orphans
|
||||||
|
|
||||||
verify_stack_health
|
verify_stack_health
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,30 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
echo "Error: docker command not found. Install Docker first."
|
echo "Error: docker command not found. Install Docker first."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Stopping Inventarsystem Docker stack..."
|
echo "Stopping Inventarsystem Docker stack..."
|
||||||
docker compose down
|
docker compose -f "$COMPOSE_FILE" down
|
||||||
|
|
||||||
echo "Stack stopped."
|
echo "Stack stopped."
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
|||||||
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
||||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||||
DIST_DIR="$PROJECT_DIR/dist"
|
DIST_DIR="$PROJECT_DIR/dist"
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
||||||
@@ -118,6 +119,41 @@ server {
|
|||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
|
-h, --help Show this help message
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_message "ERROR: Unknown option: $1"
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -360,6 +396,13 @@ deploy() {
|
|||||||
local tag="$1"
|
local tag="$1"
|
||||||
local meta_file="$2"
|
local meta_file="$2"
|
||||||
local app_image="${APP_IMAGE_REPO}:${tag}"
|
local app_image="${APP_IMAGE_REPO}:${tag}"
|
||||||
|
local compose_path
|
||||||
|
|
||||||
|
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||||
|
if [ ! -f "$compose_path" ]; then
|
||||||
|
log_message "ERROR: compose file not found: $compose_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
@@ -385,15 +428,15 @@ EOF
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
|
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
|
||||||
docker compose --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||||
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_stack_health() {
|
verify_stack_health() {
|
||||||
local compose_args running_services
|
local compose_args running_services
|
||||||
local https_port
|
local https_port
|
||||||
compose_args=(--env-file "$ENV_FILE")
|
compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
|
||||||
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
|
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
|
||||||
if [ -z "$https_port" ]; then
|
if [ -z "$https_port" ]; then
|
||||||
https_port="443"
|
https_port="443"
|
||||||
@@ -418,6 +461,8 @@ verify_stack_health() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
ensure_runtime_dependencies
|
ensure_runtime_dependencies
|
||||||
ensure_tls_certificates
|
ensure_tls_certificates
|
||||||
ensure_nginx_config_mount_source
|
ensure_nginx_config_mount_source
|
||||||
|
|||||||
Reference in New Issue
Block a user