Compare commits

..

15 Commits

Author SHA1 Message Date
Aiirondev_dev 23d9d0871a feat: implement customizable filter names and update management interfaces 2026-05-18 23:20:52 +02:00
Aiirondev_dev 576d31a384 feat: add edit functionality for predefined filter values in filter management 2026-05-18 22:01:37 +02:00
Aiirondev_dev c43bb30301 feat: enhance bookmark and favorite button styles for dark mode 2026-05-18 18:30:14 +02:00
Aiirondev_dev 7eeea1d319 feat: update dark theme colors and add border radius for improved aesthetics 2026-05-18 17:49:42 +02:00
Aiirondev_dev 56486bcbb2 feat: update address details in impressum section 2026-05-18 17:39:19 +02:00
Aiirondev_dev 2c3fb779ce feat: update styles for dark mode and enhance filter handling in admin template 2026-05-18 17:35:02 +02:00
Aiirondev_dev 299d11c1d0 feat: implement DOM virtualization for item cards in main and admin templates 2026-05-18 17:06:46 +02:00
Aiirondev_dev 4d1f95cb1f feat: improve filter3 options handling and enhance item card visibility 2026-05-18 16:53:11 +02:00
Aiirondev_dev 6b197fd9d1 feat: add third filter management and update filter handling logic 2026-05-18 16:40:11 +02:00
Aiirondev_dev 58064ee249 Changes to be committed:
modified:   Web/user.py
2026-05-13 16:05:13 +02:00
Aiirondev_dev f7573b9a62 feat: remove back navigation link from upload admin template 2026-05-12 09:32:13 +02:00
Aiirondev_dev 85d758fadb feat: update application image version to v0.7.42 2026-05-12 09:08:38 +02:00
Aiirondev_dev 212dd2abcf feat: Add 404 error handling for missing templates and files 2026-05-12 09:07:37 +02:00
Aiirondev_dev 689819df08 feat: update application image version and enhance docker-compose configuration 2026-05-12 08:16:04 +02:00
Aiirondev_dev 457d32a087 feat: Module fallback redirects and duplicate item modal redirects 2026-05-12 08:00:02 +02:00
18 changed files with 788 additions and 139 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=10000 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTP_PORTS=10000 INVENTAR_HTTP_PORTS=10000
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
INVENTAR_APP_CPUS=1.0 INVENTAR_APP_CPUS=1.0
INVENTAR_APP_MEM_LIMIT=384m INVENTAR_APP_MEM_LIMIT=384m
INVENTAR_APP_MEM_SWAP_LIMIT=768m INVENTAR_APP_MEM_SWAP_LIMIT=768m
+1
View File
@@ -0,0 +1 @@
v0.7.42
+111 -21
View File
@@ -25,9 +25,10 @@ Features:
- Booking and reservation of items - Booking and reservation of items
""" """
from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file, abort
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from jinja2 import TemplateNotFound
import user as us import user as us
import items as it import items as it
import ausleihung as au import ausleihung as au
@@ -353,7 +354,7 @@ def _enforce_active_session_user():
@app.before_request @app.before_request
def _enforce_module_access(): def _enforce_module_access():
endpoint = request.endpoint or '' endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static') or request.path.startswith('/api/'): if endpoint == 'static' or endpoint.startswith('static'):
return None return None
for name, matcher in cfg.MODULES._path_matchers.items(): for name, matcher in cfg.MODULES._path_matchers.items():
@@ -363,7 +364,18 @@ def _enforce_module_access():
'inventory': "Inventar-Modul ist deaktiviert.", 'inventory': "Inventar-Modul ist deaktiviert.",
'student_cards': "Schülerausweis-Modul ist deaktiviert." 'student_cards': "Schülerausweis-Modul ist deaktiviert."
}.get(name, f"{name.capitalize()}-Modul ist deaktiviert.") }.get(name, f"{name.capitalize()}-Modul ist deaktiviert.")
return msg, 403
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': msg}), 403
flash(msg, 'info')
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
return redirect(url_for('home'))
elif name != 'library' and cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_view'))
return redirect(url_for('my_borrowed_items'))
""" -----------------------------------------------------------After Request Handlers----------------------------------------------------------------------------- """ """ -----------------------------------------------------------After Request Handlers----------------------------------------------------------------------------- """
@@ -401,6 +413,26 @@ def _set_security_headers(response):
return response return response
@app.errorhandler(TemplateNotFound)
def handle_template_not_found(e):
"""Handle missing template files with a 404 response."""
app.logger.error(f"Template not found: {e.name}")
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Template not found', 'status': 404}), 404
return abort(404)
@app.errorhandler(404)
def handle_not_found(e):
"""Handle 404 errors with a user-friendly response."""
app.logger.warning(f"404 Not Found: {request.path}")
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Not found', 'status': 404}), 404
if 'username' in session:
return render_template('error.html', error_code=404, error_message='Seite nicht gefunden.'), 404
return redirect(url_for('login'))
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'): def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}: if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
return jsonify({'error': message}), 400 return jsonify({'error': message}), 400
@@ -2619,7 +2651,8 @@ def home():
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
open_item=request.args.get('open_item')
) )
else: else:
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user') permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
@@ -2662,6 +2695,7 @@ def home_admin():
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
school_info=_get_school_info_for_export(), school_info=_get_school_info_for_export(),
open_item=request.args.get('open_item')
) )
@@ -4667,16 +4701,26 @@ def upload_item():
# Check if base code is unique for single-item uploads # Check if base code is unique for single-item uploads
if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]): if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]):
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.' existing_item = it._get_items_collection().find_one({"code_4": code_4[0]})
if is_mobile: if existing_item:
return jsonify({'success': False, 'message': error_msg}), 400 error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
if is_mobile:
return jsonify({
'success': False,
'message': error_msg,
'existing_item_id': str(existing_item['_id']),
'redirect_to_item': True
}), 400
flash(error_msg, 'info')
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
else: else:
conflicting_items = it.get_item_by_code_4(code_4[0]) error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.'
if conflicting_items: if is_mobile:
flash('Der Code wird bereits verwendet. Das existierende Element wurde geöffnet.', 'info') return jsonify({'success': False, 'message': error_msg}), 400
return redirect(url_for(success_redirect_endpoint, open_item_modal=str(conflicting_items[0]['_id']))) else:
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint)) return redirect(url_for(success_redirect_endpoint))
# Validate optional per-item codes # Validate optional per-item codes
if individual_codes: if individual_codes:
@@ -4699,10 +4743,6 @@ def upload_item():
error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.' error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.'
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
conflicting_items = it.get_item_by_code_4(specific_code)
if conflicting_items:
flash(f'Der Einzelcode "{specific_code}" wird bereits verwendet. Das existierende Element wurde geöffnet.', 'info')
return redirect(url_for(success_redirect_endpoint, open_item_modal=str(conflicting_items[0]['_id'])))
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint)) return redirect(url_for(success_redirect_endpoint))
@@ -5834,10 +5874,6 @@ def edit_item(id):
# Check if code is unique (excluding the current item) # Check if code is unique (excluding the current item)
if code_4 and not it.is_code_unique(code_4, exclude_id=id): if code_4 and not it.is_code_unique(code_4, exclude_id=id):
conflicting_items = it.get_item_by_code_4(code_4)
if conflicting_items:
flash('Der Code wird bereits verwendet. Das existierende Element wurde stattdessen geöffnet.', 'info')
return redirect(url_for('home_admin', open_item_modal=str(conflicting_items[0]['_id'])))
flash('Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.', 'error') flash('Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.', 'error')
return redirect(url_for('home_admin')) return redirect(url_for('home_admin'))
@@ -8551,11 +8587,17 @@ def manage_filters():
# Get predefined filter values # Get predefined filter values
filter1_values = it.get_predefined_filter_values(1) filter1_values = it.get_predefined_filter_values(1)
filter2_values = it.get_predefined_filter_values(2) filter2_values = it.get_predefined_filter_values(2)
filter3_values = it.get_predefined_filter_values(3)
# Get custom filter names
filter_names = it.get_filter_names()
return render_template( return render_template(
'manage_filters.html', 'manage_filters.html',
filter1_values=filter1_values, filter1_values=filter1_values,
filter2_values=filter2_values, filter2_values=filter2_values,
filter3_values=filter3_values,
filter_names=filter_names,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards') student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
) )
@@ -8619,6 +8661,41 @@ def remove_filter_value(filter_num, value):
return redirect(url_for('manage_filters')) return redirect(url_for('manage_filters'))
@app.route('/edit_filter_value/<int:filter_num>/<string:old_value>', methods=['POST'])
def edit_filter_value(filter_num, old_value):
"""
Edit a predefined value from the specified filter.
Args:
filter_num (int): Filter number (1, 2 or 3)
old_value (str): Value to edit
Returns:
flask.Response: Redirect to filter management page
"""
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({'success': False, 'error': 'Not authorized'}), 403
new_value = sanitize_form_value(request.form.get('new_value'))
if not new_value:
flash('Bitte geben Sie einen neuen Wert ein', 'error')
return redirect(url_for('manage_filters'))
if new_value == FILTER_SELECT_ALL_TOKEN:
flash('Dieser Wert ist reserviert und kann nicht als Filterwert gespeichert werden.', 'error')
return redirect(url_for('manage_filters'))
# Update the value from the filter
success = it.edit_predefined_filter_value(filter_num, old_value, new_value)
if success:
flash(f'Wert "{old_value}" wurde in "{new_value}" bei Filter {filter_num} geändert', 'success')
else:
flash(f'Fehler beim Ändern des Wertes oder Wert existiert bereits in Filter {filter_num}', 'error')
return redirect(url_for('manage_filters'))
@app.route('/get_predefined_filter_values/<int:filter_num>') @app.route('/get_predefined_filter_values/<int:filter_num>')
def get_predefined_filter_values(filter_num): def get_predefined_filter_values(filter_num):
""" """
@@ -9585,14 +9662,27 @@ def admin_school_settings():
try: try:
updated_school = cfg.update_school_info(school_info) updated_school = cfg.update_school_info(school_info)
current_school = updated_school current_school = updated_school
# Also process filter names
filter_name_1 = request.form.get('filter_name_1')
if filter_name_1: it.set_filter_name(1, filter_name_1.strip())
filter_name_2 = request.form.get('filter_name_2')
if filter_name_2: it.set_filter_name(2, filter_name_2.strip())
filter_name_3 = request.form.get('filter_name_3')
if filter_name_3: it.set_filter_name(3, filter_name_3.strip())
flash('Schulstammdaten wurden erfolgreich gespeichert.', 'success') flash('Schulstammdaten wurden erfolgreich gespeichert.', 'success')
except Exception as exc: except Exception as exc:
app.logger.error(f'Could not update school settings: {exc}\n{traceback.format_exc()}') app.logger.error(f'Could not update school settings: {exc}\n{traceback.format_exc()}')
flash('Die Schulstammdaten konnten nicht gespeichert werden.', 'error') flash('Die Schulstammdaten konnten nicht gespeichert werden.', 'error')
# Get current filter names to display in form
filter_names = it.get_filter_names()
return render_template( return render_template(
'admin_school_settings.html', 'admin_school_settings.html',
school_info=current_school, school_info=current_school,
filter_names=filter_names,
tenant_id=tenant_id, tenant_id=tenant_id,
tenant_db=tenant_db, tenant_db=tenant_db,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
+86 -3
View File
@@ -570,7 +570,10 @@ def get_primary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(1)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving primary filters: {e}") print(f"Error retrieving primary filters: {e}")
return [] return []
@@ -589,7 +592,10 @@ def get_secondary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(2)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving secondary filters: {e}") print(f"Error retrieving secondary filters: {e}")
return [] return []
@@ -608,7 +614,10 @@ def get_tertiary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(3)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving tertiary filters: {e}") print(f"Error retrieving tertiary filters: {e}")
return [] return []
@@ -785,6 +794,80 @@ def remove_predefined_filter_value(filter_num, value):
return result.modified_count > 0 return result.modified_count > 0
def edit_predefined_filter_value(filter_num, old_value, new_value):
"""
Edit a predefined value from a filter and update all matching items.
Args:
filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe)
old_value (str): Value to replace
new_value (str): New value
Returns:
bool: True if value was updated, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
filter_presets = db['filter_presets']
# Check if the new value already exists
existing = filter_presets.find_one({
'filter_num': filter_num,
'values': new_value
})
if existing and old_value != new_value:
client.close()
return False
# Update the value in the filter
result = filter_presets.update_one(
{'filter_num': filter_num, 'values': old_value},
{'$set': {'values.$': new_value}}
)
if result.modified_count > 0:
items = db['items']
filter_field = 'Filter' if filter_num == 1 else f'Filter{filter_num}'
# Also update all items that use this filter
items.update_many(
{filter_field: old_value},
{'$set': {filter_field: new_value}}
)
client.close()
return result.modified_count > 0
def get_filter_names():
"""Get customized filter category names."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names_doc = db.settings.find_one({'setting_type': 'filter_names'})
client.close()
if names_doc and 'names' in names_doc:
return names_doc['names']
return {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
}
def set_filter_name(filter_num, name):
"""Set custom name for a filter category."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = get_filter_names()
names[str(filter_num)] = name
db.settings.update_one(
{'setting_type': 'filter_names'},
{'$set': {'names': names}},
upsert=True
)
client.close()
return True
# === LOCATION MANAGEMENT === # === LOCATION MANAGEMENT ===
def get_predefined_locations(): def get_predefined_locations():
+8 -8
View File
@@ -10,13 +10,13 @@
.planned-appointments-panel { .planned-appointments-panel {
margin: 15px 0; margin: 15px 0;
padding: 15px; padding: 15px;
background-color: #e1f5fe; background-color: var(--ui-surface-soft, #e1f5fe);
border-left: 4px solid #03a9f4; border-left: 4px solid #03a9f4;
border-radius: 4px; border-radius: 4px;
} }
.planned-appointments-panel h4 { .planned-appointments-panel h4 {
color: #0277bd; color: var(--ui-title, #0277bd);
margin-top: 0; margin-top: 0;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 1.1em; font-size: 1.1em;
@@ -30,9 +30,9 @@
.appointment-item { .appointment-item {
padding: 10px; padding: 10px;
background-color: #f9fdff; background-color: var(--ui-surface, #f9fdff);
border-radius: 4px; border-radius: 4px;
border: 1px solid #b3e5fc; border: 1px solid var(--ui-border, #b3e5fc);
} }
.appointment-header { .appointment-header {
@@ -44,7 +44,7 @@
.appointment-date { .appointment-date {
font-weight: bold; font-weight: bold;
color: #0288d1; color: var(--ui-title, #0288d1);
} }
.appointment-period { .appointment-period {
@@ -86,14 +86,14 @@
.appointment-user { .appointment-user {
margin-left: auto; margin-left: auto;
font-style: italic; font-style: italic;
color: #546e7a; color: var(--ui-text-muted, #546e7a);
} }
.appointment-notes { .appointment-notes {
font-size: 0.9em; font-size: 0.9em;
color: #455a64; color: var(--ui-text, #455a64);
margin-top: 5px; margin-top: 5px;
padding-top: 5px; padding-top: 5px;
border-top: 1px solid #e1f5fe; border-top: 1px solid var(--ui-border, #e1f5fe);
font-style: italic; font-style: italic;
} }
+112 -10
View File
@@ -22,16 +22,21 @@
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--ui-bg: #0f172a; /* Tiefes Blau-Theme (Dark Blue) */
--ui-bg-accent: #1e293b; --ui-bg: #0b1120;
--ui-bg-accent: #111827;
--ui-surface: #1e293b; --ui-surface: #1e293b;
--ui-surface-soft: #25314a; --ui-surface-soft: #334155;
--ui-border: #334155; --ui-border: #475569;
--ui-text: #f1f5f9; --ui-text: #f8fafc;
--ui-text-muted: #94a3b8; --ui-text-muted: #94a3b8;
--ui-title: #ffffff; --ui-title: #ffffff;
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4); --ui-shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5); --ui-shadow-md: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
/* Abgerundetes Format im gesamten Dark Mode */
--ui-radius: 20px;
--ui-btn-radius: 99px; /* Pillenform für Buttons */
} }
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */ /* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
@@ -57,9 +62,17 @@ body {
:root[data-theme="dark"] input, :root[data-theme="dark"] input,
:root[data-theme="dark"] select, :root[data-theme="dark"] select,
:root[data-theme="dark"] textarea { :root[data-theme="dark"] textarea {
background-color: var(--ui-surface-soft) !important; background-color: var(--ui-bg) !important;
color: var(--ui-text) !important; color: var(--ui-text) !important;
border-color: var(--ui-border) !important; border: 1px solid var(--ui-border) !important;
border-radius: 12px !important;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3) !important;
}
:root[data-theme="dark"] input:focus,
:root[data-theme="dark"] select:focus,
:root[data-theme="dark"] textarea:focus {
border-color: #52a8ff !important;
box-shadow: 0 0 0 2px rgba(82, 168, 255, 0.2), inset 0 1px 2px rgba(0,0,0,0.3) !important;
} }
body { body {
@@ -295,7 +308,7 @@ select:focus {
/* Modal dialog styling */ /* Modal dialog styling */
.modal-dialog-white { .modal-dialog-white {
background: white; background: var(--ui-surface);
padding: 20px; padding: 20px;
border-radius: 5px; border-radius: 5px;
text-align: center; text-align: center;
@@ -896,3 +909,92 @@ html, body {
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
} }
/* ========================================================= */
/* UNIVERSAL DARK MODE OVERRIDES FOR HARDCODED HTML STYLES */
/* ========================================================= */
:root[data-theme="dark"] .bg-white,
:root[data-theme="dark"] .bg-light,
:root[data-theme="dark"] [style*="background-color: #fff"],
:root[data-theme="dark"] [style*="background-color: #ffffff"],
:root[data-theme="dark"] [style*="background-color: #f8f9fa"],
:root[data-theme="dark"] [style*="background-color: #f5f5f5"],
:root[data-theme="dark"] [style*="background: #fff"],
:root[data-theme="dark"] [style*="background: #ffffff"],
:root[data-theme="dark"] [style*="background: #f8f9fa"],
:root[data-theme="dark"] [style*="background: #f5f5f5"],
:root[data-theme="dark"] [style*="background: white"],
:root[data-theme="dark"] [style*="background-color: white"],
:root[data-theme="dark"] .modal-content,
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .dropdown-menu {
background-color: var(--ui-surface) !important;
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .text-dark,
:root[data-theme="dark"] .text-black,
:root[data-theme="dark"] [style*="color: #000"],
:root[data-theme="dark"] [style*="color: rgb(0, 0, 0)"],
:root[data-theme="dark"] [style*="color: black"],
:root[data-theme="dark"] [style*="color: #333"] {
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .text-muted {
color: var(--ui-text-muted) !important;
}
/* Fix table backgrounds that might have hardcoded light colors */
:root[data-theme="dark"] .table-light,
:root[data-theme="dark"] table.table,
:root[data-theme="dark"] table [style*="background: #fff"] {
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
}
/* Fix list items */
:root[data-theme="dark"] .list-group-item {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
/* Additional inline style coverage */
:root[data-theme="dark"] [style*="background: #eef4ff"],
:root[data-theme="dark"] [style*="background-color: #eef4ff"],
:root[data-theme="dark"] [style*="background: #e9ecef"],
:root[data-theme="dark"] [style*="background-color: #e9ecef"],
:root[data-theme="dark"] [style*="background: #e2e8f0"],
:root[data-theme="dark"] [style*="background-color: #e2e8f0"],
:root[data-theme="dark"] [style*="background-color: #fefefe"],
:root[data-theme="dark"] [style*="background: #fffdfd"] {
background-color: var(--ui-surface) !important;
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .badge-light,
:root[data-theme="dark"] .bg-light {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border: 1px solid var(--ui-border);
}
/* Fix Bookmark/Favorite Button in Dark Mode */
:root[data-theme="dark"] .bookmark-btn {
background: var(--ui-surface-soft) !important;
border-color: #f59e0b !important;
color: #f59e0b !important;
}
:root[data-theme="dark"] .bookmark-btn:hover,
:root[data-theme="dark"] .bookmark-btn.active {
background: #f59e0b !important;
color: var(--ui-bg) !important;
}
:root[data-theme="dark"] .favorite-item {
outline: 2px solid #f59e0b !important;
}
+19
View File
@@ -71,6 +71,25 @@
<small>Upload ersetzt das bisherige Logo. Wenn kein neues Logo gewählt wird, bleibt das aktuelle erhalten.</small> <small>Upload ersetzt das bisherige Logo. Wenn kein neues Logo gewählt wird, bleibt das aktuelle erhalten.</small>
</div> </div>
<hr style="margin: 24px 0; border: 0; border-top: 1px solid #e5e7eb;">
<h3 style="font-size: 1.1rem; margin-bottom: 16px; color: #111827;">Globales Filter-Wording</h3>
<p style="font-size: 0.9rem; color: #6b7280; margin-bottom: 16px;">Hier kannst du die globalen Bezeichnungen für die drei Haupt-Kategorien (Filter) der Anwendung ändern.</p>
<div class="form-row">
<div class="form-group">
<label for="filter_name_1">Name Filter 1</label>
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fach/Kategorie') }}" placeholder="z. B. Fach/Kategorie">
</div>
<div class="form-group">
<label for="filter_name_2">Name Filter 2</label>
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'System/Bereich') }}" placeholder="z. B. System/Bereich">
</div>
<div class="form-group">
<label for="filter_name_3">Name Filter 3</label>
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Typ/Art') }}" placeholder="z. B. Typ/Art">
</div>
</div>
<div class="actions-row"> <div class="actions-row">
<button type="submit" class="btn btn-primary">Schulstammdaten speichern</button> <button type="submit" class="btn btn-primary">Schulstammdaten speichern</button>
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a> <a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
+1 -1
View File
@@ -329,7 +329,7 @@
} }
/* iPad/Tablet responsive adjustments */ /* iPad/Tablet responsive adjustments */
@media (max-width: 1024px) { @media (max-width: 768px) {
.navbar .container-fluid { .navbar .container-fluid {
gap: 6px; gap: 6px;
padding-left: max(env(safe-area-inset-left, 0), 10px); padding-left: max(env(safe-area-inset-left, 0), 10px);
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}
{% if error_code == 404 %}
Seite nicht gefunden - Inventarsystem
{% else %}
Fehler - Inventarsystem
{% endif %}
{% endblock %}
{% block content %}
<div class="error-container" style="text-align: center; padding: 60px 20px; min-height: 70vh; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<div style="max-width: 600px;">
<!-- Error Code Display -->
<div style="font-size: 120px; font-weight: bold; color: #ef4444; margin-bottom: 20px;">
{{ error_code }}
</div>
<!-- Error Title -->
<h1 style="font-size: 32px; margin: 20px 0; color: #1f2937;">
{% if error_code == 404 %}
Seite nicht gefunden
{% else %}
Ein Fehler ist aufgetreten
{% endif %}
</h1>
<!-- Error Message -->
<p style="font-size: 18px; color: #6b7280; margin: 20px 0 40px;">
{{ error_message or 'Es tut uns leid, aber die angeforderte Seite konnte nicht gefunden werden.' }}
</p>
<!-- Action Buttons -->
<div style="display: flex; gap: 15px; justify-content: center; flex-wrap: wrap;">
<a href="{{ url_for('home') }}" class="button" style="background-color: #3b82f6; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zur Startseite
</a>
<a href="javascript:history.back()" class="button" style="background-color: #6b7280; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zurück
</a>
</div>
<!-- Additional Help -->
<div style="margin-top: 60px; padding: 20px; background-color: #f3f4f6; border-radius: 8px;">
<p style="color: #6b7280; font-size: 14px;">
Wenn Sie denken, dass dies ein Fehler ist, oder Sie Hilfe benötigen,
<a href="{{ url_for('impressum') }}" style="color: #3b82f6; text-decoration: none;">kontaktieren Sie den Administrator</a>.
</p>
</div>
</div>
</div>
<style>
.error-container {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.button:hover {
opacity: 0.9;
transform: translateY(-2px);
transition: all 0.2s ease;
}
</style>
{% endblock %}
+4 -4
View File
@@ -12,7 +12,7 @@
{% block content %} {% block content %}
<div class="container my-4"> <div class="container my-4">
<div class="impressum-content bg-white p-4 shadow rounded"> <div class="impressum-content p-4 shadow rounded" style="background: var(--ui-surface); color: var(--ui-text);">
<h1 class="mb-4 text-center">Impressum</h1> <h1 class="mb-4 text-center">Impressum</h1>
<p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p> <p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p>
@@ -20,7 +20,7 @@
<h3 class="mb-3">Kontaktinformationen</h3> <h3 class="mb-3">Kontaktinformationen</h3>
<address class="mb-0"> <address class="mb-0">
<p><strong>Name:</strong> Invario UG</p> <p><strong>Name:</strong> Invario UG</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p> <p><strong>Adresse:</strong> Am Sportplatz 10<br>83052 Bruckmühl<br>Deutschland</p>
</address> </address>
<p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p> <p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p>
</div> </div>
@@ -29,8 +29,8 @@
<h3 class="mb-3">Verantwortlich für den Inhalt</h3> <h3 class="mb-3">Verantwortlich für den Inhalt</h3>
<p>(nach § 18 Abs. 2 MStV)</p> <p>(nach § 18 Abs. 2 MStV)</p>
<p>Invario UG<br> <p>Invario UG<br>
Musterstraße 123<br> Am Sportplatz 10<br>
12345 Musterstadt<br> 83052 Bruckmühl<br>
Deutschland</p> Deutschland</p>
</div> </div>
+78 -9
View File
@@ -751,6 +751,7 @@
let mainItemsSentinel = null; let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null; let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false; let mainItemsScrollPrefetchBound = false;
let cardVirtualizationObserver = null;
function ensureMainItemsLoadingIndicator() { function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -759,11 +760,14 @@
if (!mainItemsLoadingIndicator) { if (!mainItemsLoadingIndicator) {
mainItemsLoadingIndicator = document.createElement('div'); mainItemsLoadingIndicator = document.createElement('div');
mainItemsLoadingIndicator.id = 'main-items-loading-indicator'; mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator'; mainItemsLoadingIndicator.className = 'items-loading-indicator empty-frames-placeholder';
mainItemsLoadingIndicator.innerHTML = ` mainItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div> <div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px;"></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div> <div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-md-block"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-lg-block"></div>
`; `;
mainItemsLoadingIndicator.style.display = 'flex';
mainItemsLoadingIndicator.style.width = '100%';
} }
itemsContainer.appendChild(mainItemsLoadingIndicator); itemsContainer.appendChild(mainItemsLoadingIndicator);
@@ -1189,6 +1193,57 @@
mainItemsScrollPrefetchBound = true; mainItemsScrollPrefetchBound = true;
} }
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
card.classList.remove('empty-frame');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
card.classList.add('empty-frame');
}
}
}
});
}, {
rootMargin: '30000px 30000px 30000px 30000px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainItemsHasMore) return; if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => { mainItemsObserver = new IntersectionObserver(entries => {
@@ -2419,29 +2474,29 @@
// Add these functions right after the loadFilterState() function // Add these functions right after the loadFilterState() function
function rebuildFilter3Options() { function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return; if (!allItems) return;
// Get current filter 1 and filter 2 selections // Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1; const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2; const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values // If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) { if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values // Include dynamically added ones from loaded items too, just in case
const allFilter3Values = new Set(); const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => { allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []); (item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => { filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') { if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value); combinedFilter3Values.add(value);
} }
}); });
}); });
// Populate filter 3 dropdown with all values // Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3); populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return; return;
} }
@@ -3114,6 +3169,8 @@
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
position: relative; position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 450px;
} }
.item-card:hover { .item-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
@@ -4614,6 +4671,18 @@ function openItemQuick(id){
} }
} }
</style> </style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}
<style> <style>
/* Admin/Main Content Container align */ /* Admin/Main Content Container align */
+107 -27
View File
@@ -403,6 +403,8 @@
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
position: relative; position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 450px;
} }
.item-card.bulk-selected { .item-card.bulk-selected {
@@ -2738,6 +2740,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
let descSearchIds = null; // Set of matching IDs or null when disabled let descSearchIds = null; // Set of matching IDs or null when disabled
let currentUsername = ''; let currentUsername = '';
let allItems = []; // Cache for all items let allItems = []; // Cache for all items
let allFilter1Values = new Set();
let allFilter2Values = new Set();
let allFilter3Values = new Set();
let bulkDeleteSelection = new Set(); let bulkDeleteSelection = new Set();
let bulkDeleteDrawerOpen = false; let bulkDeleteDrawerOpen = false;
const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }}; const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }};
@@ -2876,30 +2881,29 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
function rebuildFilter3Options() { function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return; if (!allItems) return;
// Get current filter 1 and filter 2 selections // Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1; const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2; const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values // If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) { if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values // Include dynamically added ones from loaded items too, just in case
const allFilter3Values = new Set(); const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => { allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []); (item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => { filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') { if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value); combinedFilter3Values.add(value);
} }
}); });
}); });
// Populate filter 3 dropdown with all values // Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3); populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return;
} }
// Filter items based on filter1 and filter2 selections // Filter items based on filter1 and filter2 selections
@@ -3252,18 +3256,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const openItem = urlParams.get('open_item_modal');
if (openItem) {
openEditModalFromServer(openItem);
}
// Load saved filters // Load saved filters
loadFilterState(); loadFilterState();
// Load predefined filter values for dropdowns // Load predefined filter values for dropdowns
loadPredefinedFilterValues(1); loadPredefinedFilterValues(1);
loadPredefinedFilterValues(2); loadPredefinedFilterValues(2);
loadPredefinedFilterValues(3);
// Set up edit form submission // Set up edit form submission
setupEditFormSubmission(); setupEditFormSubmission();
@@ -3288,11 +3287,24 @@ document.addEventListener('DOMContentLoaded', ()=>{
}); });
}); });
// Fetch user status to get current username // Fetch user status and global filters
fetch('/user_status') Promise.all([
.then(response => response.json()) fetch('/user_status').then(response => response.json()),
.then(data => { fetch('/get_filter').then(response => response.json())
currentUsername = data.username; ])
.then(([userData, filterData]) => {
currentUsername = userData.username;
if (filterData.filter1) {
allFilter1Values = new Set(filterData.filter1.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter2) {
allFilter2Values = new Set(filterData.filter2.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter3) {
allFilter3Values = new Set(filterData.filter3.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
// Now load items with username information // Now load items with username information
loadItems(); loadItems();
// Setup card images display after a short delay to ensure items are loaded // Setup card images display after a short delay to ensure items are loaded
@@ -3301,7 +3313,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}, 300); }, 300);
}) })
.catch(error => { .catch(error => {
console.error('Error fetching user status:', error); console.error('Error fetching user status or filters:', error);
loadItems(); // Load items anyway in case of error loadItems(); // Load items anyway in case of error
}); });
@@ -3362,6 +3374,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
let mainAdminItemsLoadingIndicator = null; let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false; let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0; let totalItemsInSystem = 0;
let cardVirtualizationObserver = null;
function ensureMainAdminItemsLoadingIndicator() { function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -3370,11 +3383,14 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (!mainAdminItemsLoadingIndicator) { if (!mainAdminItemsLoadingIndicator) {
mainAdminItemsLoadingIndicator = document.createElement('div'); mainAdminItemsLoadingIndicator = document.createElement('div');
mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator'; mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator'; mainAdminItemsLoadingIndicator.className = 'items-loading-indicator empty-frames-placeholder';
mainAdminItemsLoadingIndicator.innerHTML = ` mainAdminItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div> <div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px;"></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div> <div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-md-block"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-lg-block"></div>
`; `;
mainAdminItemsLoadingIndicator.style.display = 'flex';
mainAdminItemsLoadingIndicator.style.width = '100%';
} }
itemsContainer.appendChild(mainAdminItemsLoadingIndicator); itemsContainer.appendChild(mainAdminItemsLoadingIndicator);
@@ -3701,10 +3717,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
}); });
// Populate filter options // Populate filter options using server-provided full filter lists when available.
populateFilterOptions('filter1-options', [...filter1Values].sort(), 1); populateFilterOptions('filter1-options', allFilter1Values.size ? [...allFilter1Values].sort() : [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2); populateFilterOptions('filter2-options', allFilter2Values.size ? [...allFilter2Values].sort() : [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3); populateFilterOptions('filter3-options', allFilter3Values.size ? [...allFilter3Values].sort() : [...filter3Values].sort(), 3);
if (!append) { if (!append) {
itemsContainer.scrollLeft = 0; itemsContainer.scrollLeft = 0;
@@ -3789,6 +3805,58 @@ document.addEventListener('DOMContentLoaded', ()=>{
mainAdminItemsScrollPrefetchBound = true; mainAdminItemsScrollPrefetchBound = true;
} }
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
card.classList.remove('empty-frame');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container') ||
e.target.closest('.bulk-delete-toggle') || e.target.closest('label')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
card.classList.add('empty-frame');
}
}
}
});
}, {
rootMargin: '30000px 30000px 30000px 30000px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainAdminItemsHasMore) return; if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => { mainAdminItemsObserver = new IntersectionObserver(entries => {
@@ -5722,4 +5790,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
} }
</style> </style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}
+105 -22
View File
@@ -15,16 +15,16 @@
<h1 class="mb-4">Filterwerte verwalten</h1> <h1 class="mb-4">Filterwerte verwalten</h1>
<div class="row"> <div class="row">
<!-- Filter 1: Unterrichtsfach --> <!-- Filter 1 -->
<div class="col-md-6"> <div class="col-md-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h2 class="card-title h5 mb-0">Unterrichtsfach (Filter 1)</h2> <h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Fach/Kategorie') }} (Filter 1)</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neues Unterrichtsfach..." required> <input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary">Hinzufügen</button>
</div> </div>
</form> </form>
@@ -33,13 +33,25 @@
{% if filter1_values %} {% if filter1_values %}
<div class="list-group"> <div class="list-group">
{% for value in filter1_values %} {% for value in filter1_values %}
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item">
{{ value }} <div class="d-flex justify-content-between align-items-center mb-2">
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline"> <span>{{ value }}</span>
<button type="submit" class="btn btn-sm btn-danger" <div>
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');"> <button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-1-{{ loop.index }}')">Bearbeiten</button>
Entfernen <form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
</button> <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">Abbrechen</button>
</div>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
@@ -51,16 +63,16 @@
</div> </div>
</div> </div>
<!-- Filter 2: Jahrgangsstufe --> <!-- Filter 2 -->
<div class="col-md-6"> <div class="col-md-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h2 class="card-title h5 mb-0">Jahrgangsstufe (Filter 2)</h2> <h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'System/Bereich') }} (Filter 2)</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neue Jahrgangsstufe..." required> <input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary">Hinzufügen</button>
</div> </div>
</form> </form>
@@ -69,13 +81,73 @@
{% if filter2_values %} {% if filter2_values %}
<div class="list-group"> <div class="list-group">
{% for value in filter2_values %} {% for value in filter2_values %}
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item">
{{ value }} <div class="d-flex justify-content-between align-items-center mb-2">
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline"> <span>{{ value }}</span>
<button type="submit" class="btn btn-sm btn-danger" <div>
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');"> <button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-2-{{ loop.index }}')">Bearbeiten</button>
Entfernen <form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
</button> <button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">Abbrechen</button>
</div>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
<!-- Filter 3 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">{{ filter_names.get('3', 'Typ/Art') }} (Filter 3)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=3) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter3_values %}
<div class="list-group">
{% for value in filter3_values %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ value }}</span>
<div>
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-3-{{ loop.index }}')">Bearbeiten</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=3, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-3-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=3, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-3-{{ loop.index }}')">Abbrechen</button>
</div>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
@@ -97,4 +169,15 @@
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a> <a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div> </div>
</div> </div>
<script>
function toggleEdit(id) {
const el = document.getElementById(id);
if (el.style.display === 'none') {
el.style.display = 'block';
} else {
el.style.display = 'none';
}
}
</script>
{% endblock %} {% endblock %}
-1
View File
@@ -709,7 +709,6 @@
</style> </style>
<div class="upload-container"> <div class="upload-container">
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
{% if show_library_features %} {% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
+32
View File
@@ -0,0 +1,32 @@
from pymongo import MongoClient
import Web.settings as cfg
def get_filter_names():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = db.settings.find_one({'setting_type': 'filter_names'})
client.close()
if names:
return names.get('names', {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
})
return {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
}
def set_filter_name(filter_num, name):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = get_filter_names()
names[str(filter_num)] = name
db.settings.update_one(
{'setting_type': 'filter_names'},
{'$set': {'names': names}},
upsert=True
)
client.close()
return True
+1 -7
View File
@@ -498,8 +498,6 @@ def check_nm_pwd(username, password):
finally: finally:
client.close() client.close()
return None
def add_user( def add_user(
username, username,
@@ -536,16 +534,12 @@ def add_user(
for key, value in page_permissions.items(): for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value) permission_defaults['pages'][str(key)] = bool(value)
alias_first = name if str(name or '').strip() else username
alias_last = last_name if str(last_name or '').strip() else ''
name_alias = build_name_synonym(alias_first, alias_last)
user_doc = { user_doc = {
'Username': username, 'Username': username,
'Password': hashing(password), 'Password': hashing(password),
'Admin': False, 'Admin': False,
'active_ausleihung': None, 'active_ausleihung': None,
'name': name_alias, 'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '', 'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student), 'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'], 'PermissionPreset': permission_defaults['preset'],
Regular → Executable
View File
+47 -25
View File
@@ -1,41 +1,63 @@
version: "3.8"
services: services:
app: app:
build: . image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.39
container_name: inventory-app container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
environment: ports:
- MONGO_URL=mongodb://mongodb:27017/inventar - "10000:8000"
- REDIS_URL=redis://redis:6379
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
depends_on: depends_on:
- mongodb - mongodb
- redis - redis
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
mongodb: mongodb:
image: mongo:latest image: mongo:7.0
container_name: mongodb container_name: inventarsystem-mongodb
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- mongo_data:/data/db - mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis: redis:
image: redis:alpine image: redis:7-alpine
container_name: redis container_name: inventarsystem-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
cloudflared: ports:
image: cloudflare/cloudflared:latest - "6379:6379"
container_name: cloudflared
restart: unless-stopped
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
command: tunnel run homeserver
volumes: volumes:
- ./config.yml:/etc/cloudflared/config.yml - redis_data:/data
- ./credentials.json:/etc/cloudflared/credentials.json healthcheck:
depends_on: test: ["CMD", "redis-cli", "ping"]
- app interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
mongo_data: mongodb_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
redis_data: