feat: Add iOS-specific enhancements and mobile compatibility utilities
- Implemented `remove_key` function in verify.py to allow key removal by user ID. - Updated run.sh to include additional dependencies for QR code and TOTP support. - Introduced ios_fixes.js for iOS-specific enhancements including file input handling, form submission fixes, and image handling optimizations. - Added mobile_compatibility.js for mobile device detection and utility functions to improve compatibility, especially for iOS. - Created base.html template for consistent layout and styling across the application. - Developed login.html template with a password toggle feature for better user experience.
This commit is contained in:
+74
-3
@@ -1,7 +1,9 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
from flask import Flask, render_template, request, jsonify
|
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session
|
||||||
import verify
|
import verify
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
|
||||||
def _resolve_template_dir() -> Path:
|
def _resolve_template_dir() -> Path:
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -16,9 +18,10 @@ def _resolve_template_dir() -> Path:
|
|||||||
|
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
|
|
||||||
|
totp_key = "Hsdfisdf4n34234dfiseLoasjfj3asnnvhxbbfgrzzuewwndcodrweokyn"
|
||||||
|
|
||||||
app = Flask(__name__, template_folder=str(_resolve_template_dir()))
|
app = Flask(__name__, template_folder=str(_resolve_template_dir()))
|
||||||
|
app.secret_key = "Test123"
|
||||||
|
|
||||||
def _read_app_version() -> str:
|
def _read_app_version() -> str:
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -33,6 +36,13 @@ def _read_app_version() -> str:
|
|||||||
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
def generate_totp_qrcode():
|
||||||
|
uri = pyotp.totp.TOTP(totp_key).provisioning_uri(name='',issuer_name='Key Verification Server')
|
||||||
|
qrcode.make(uri).save("qr.png")
|
||||||
|
|
||||||
|
def check_totp(key):
|
||||||
|
totp = pyotp.TOTP(totp_key)
|
||||||
|
return totp.verify(key)
|
||||||
|
|
||||||
APP_VERSION = _read_app_version()
|
APP_VERSION = _read_app_version()
|
||||||
|
|
||||||
@@ -50,9 +60,70 @@ def validate__information():
|
|||||||
else:
|
else:
|
||||||
return jsonify({"status": "invalid"}), 402
|
return jsonify({"status": "invalid"}), 402
|
||||||
|
|
||||||
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
"""
|
||||||
|
User login route.
|
||||||
|
Authenticates users and redirects to appropriate homepage based on role.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
flask.Response: Rendered template or redirect
|
||||||
|
"""
|
||||||
|
if 'username' in session:
|
||||||
|
return redirect(url_for('default'))
|
||||||
|
if request.method == 'POST':
|
||||||
|
totp = request.form['password']
|
||||||
|
if not totp:
|
||||||
|
flash('Please fill all fields', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
user_log = check_totp(totp)
|
||||||
|
|
||||||
|
if user_log:
|
||||||
|
session['username'] = "Whatareyoulookingfor"
|
||||||
|
return redirect(url_for('default'))
|
||||||
|
else:
|
||||||
|
flash('Invalid credentials', 'error')
|
||||||
|
get_flashed_messages()
|
||||||
|
return render_template('login.html')
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def default():
|
def default():
|
||||||
return render_template("main.html")
|
if 'username' not in session:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
keys = verify.load_file()
|
||||||
|
return render_template("main.html", keys=keys)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/generate_new", methods=["POST"])
|
||||||
|
def generate_new():
|
||||||
|
if 'username' not in session:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
new_license = verify.new_key()
|
||||||
|
flash(f"New key generated: {new_license}", "success")
|
||||||
|
return redirect(url_for('default'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/remove_key/<user_id>", methods=["POST"])
|
||||||
|
def remove_key(user_id):
|
||||||
|
if 'username' not in session:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
if verify.remove_key(user_id):
|
||||||
|
flash(f"Key for user {user_id} removed", "success")
|
||||||
|
else:
|
||||||
|
flash(f"No key found for user {user_id}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for('default'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
def logout():
|
||||||
|
session.pop('username', None)
|
||||||
|
flash('Logged out successfully', 'info')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* iOS Specific Enhancements for Inventarsystem
|
||||||
|
*
|
||||||
|
* This file contains fixes specific to iOS devices for the Inventarsystem application,
|
||||||
|
* focusing on upload and duplication functionality.
|
||||||
|
*
|
||||||
|
* Copyright 2025 Maximilian Gründinger
|
||||||
|
* Licensed under the Apache License, Version 2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initialize when document is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (isIOSDevice()) {
|
||||||
|
console.log('iOS device detected, applying iOS-specific enhancements');
|
||||||
|
applyIOSFixes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply all iOS-specific fixes
|
||||||
|
function applyIOSFixes() {
|
||||||
|
// Fix file input issues
|
||||||
|
fixIOSFileInputs();
|
||||||
|
|
||||||
|
// Fix form submission
|
||||||
|
fixIOSFormSubmission();
|
||||||
|
|
||||||
|
// Fix image handling
|
||||||
|
fixIOSImageHandling();
|
||||||
|
|
||||||
|
// Fix duplication
|
||||||
|
fixIOSDuplication();
|
||||||
|
|
||||||
|
// Apply CSS fixes
|
||||||
|
applyIOSCSSFixes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix iOS file input issues
|
||||||
|
function fixIOSFileInputs() {
|
||||||
|
// Find all file inputs
|
||||||
|
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||||
|
|
||||||
|
fileInputs.forEach(input => {
|
||||||
|
// Create a touch-friendly wrapper
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'ios-file-input-wrapper';
|
||||||
|
wrapper.style.position = 'relative';
|
||||||
|
|
||||||
|
// Style the original input to be more touch-friendly
|
||||||
|
input.style.padding = '20px 0';
|
||||||
|
|
||||||
|
// Wrap the input
|
||||||
|
if (input.parentNode) {
|
||||||
|
input.parentNode.insertBefore(wrapper, input);
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
|
||||||
|
// Add a visible button for better touch target
|
||||||
|
const fakeButton = document.createElement('div');
|
||||||
|
fakeButton.className = 'ios-file-button';
|
||||||
|
fakeButton.textContent = 'Wählen Sie Bilder/Videos aus';
|
||||||
|
fakeButton.style.display = 'inline-block';
|
||||||
|
fakeButton.style.padding = '10px 15px';
|
||||||
|
fakeButton.style.backgroundColor = '#4CAF50';
|
||||||
|
fakeButton.style.color = 'white';
|
||||||
|
fakeButton.style.borderRadius = '4px';
|
||||||
|
fakeButton.style.textAlign = 'center';
|
||||||
|
fakeButton.style.margin = '10px 0';
|
||||||
|
fakeButton.style.fontWeight = 'bold';
|
||||||
|
wrapper.appendChild(fakeButton);
|
||||||
|
|
||||||
|
// Make sure the real input covers the fake button
|
||||||
|
input.style.position = 'absolute';
|
||||||
|
input.style.top = '0';
|
||||||
|
input.style.left = '0';
|
||||||
|
input.style.width = '100%';
|
||||||
|
input.style.height = '100%';
|
||||||
|
input.style.opacity = '0';
|
||||||
|
input.style.zIndex = '10';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom event handling
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
// Update visual feedback
|
||||||
|
const fileCount = this.files ? this.files.length : 0;
|
||||||
|
let fileText = fakeButton.textContent;
|
||||||
|
|
||||||
|
if (fileCount > 0) {
|
||||||
|
fileText = `${fileCount} Datei${fileCount !== 1 ? 'en' : ''} ausgewählt`;
|
||||||
|
fakeButton.style.backgroundColor = '#2E7D32';
|
||||||
|
} else {
|
||||||
|
fileText = 'Wählen Sie Bilder/Videos aus';
|
||||||
|
fakeButton.style.backgroundColor = '#4CAF50';
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeButton.textContent = fileText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix iOS form submission issues
|
||||||
|
function fixIOSFormSubmission() {
|
||||||
|
// Find upload forms
|
||||||
|
const uploadForms = document.querySelectorAll('form[action*="upload_item"]');
|
||||||
|
|
||||||
|
uploadForms.forEach(form => {
|
||||||
|
// Add an iOS submission handler
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
// Only intercept on iOS
|
||||||
|
if (!isIOSDevice()) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Show that we're processing
|
||||||
|
const loadingMessage = document.createElement('div');
|
||||||
|
loadingMessage.className = 'ios-loading-message';
|
||||||
|
loadingMessage.textContent = 'Wird verarbeitet...';
|
||||||
|
loadingMessage.style.padding = '10px';
|
||||||
|
loadingMessage.style.backgroundColor = '#f0f0f0';
|
||||||
|
loadingMessage.style.borderRadius = '5px';
|
||||||
|
loadingMessage.style.margin = '10px 0';
|
||||||
|
loadingMessage.style.textAlign = 'center';
|
||||||
|
form.appendChild(loadingMessage);
|
||||||
|
|
||||||
|
// Disable the submit button
|
||||||
|
const submitButton = form.querySelector('button[type="submit"]');
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use timeout to allow UI to update before heavy processing
|
||||||
|
setTimeout(() => {
|
||||||
|
// Gather form data with special handling for iOS
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
// Add iOS flag
|
||||||
|
formData.append('is_ios', 'true');
|
||||||
|
|
||||||
|
// Submit with fetch API which works better on iOS
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Remove loading message
|
||||||
|
if (loadingMessage.parentNode) {
|
||||||
|
loadingMessage.parentNode.removeChild(loadingMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable submit button
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Show success and redirect
|
||||||
|
alert(data.message || 'Element erfolgreich hinzugefügt');
|
||||||
|
|
||||||
|
// Redirect to the appropriate page
|
||||||
|
if (data.itemId) {
|
||||||
|
window.location.href = `/home_admin?highlight_item=${data.itemId}`;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/home_admin';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show error
|
||||||
|
alert(data.message || 'Ein Fehler ist aufgetreten');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('iOS form submission error:', error);
|
||||||
|
|
||||||
|
// Remove loading message
|
||||||
|
if (loadingMessage.parentNode) {
|
||||||
|
loadingMessage.parentNode.removeChild(loadingMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable submit button
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error
|
||||||
|
alert('Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut.');
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix iOS image handling issues
|
||||||
|
function fixIOSImageHandling() {
|
||||||
|
// Reduce image preview quality on iOS
|
||||||
|
if (typeof ImagePreviewGenerator !== 'undefined') {
|
||||||
|
// Override the compression quality for iOS
|
||||||
|
const originalCompressImage = ImagePreviewGenerator.prototype._compressImage;
|
||||||
|
|
||||||
|
if (originalCompressImage) {
|
||||||
|
ImagePreviewGenerator.prototype._compressImage = function(dataUrl) {
|
||||||
|
// Lower quality for iOS
|
||||||
|
this.quality = 0.5;
|
||||||
|
this.maxWidth = 600;
|
||||||
|
this.maxHeight = 600;
|
||||||
|
|
||||||
|
return originalCompressImage.call(this, dataUrl);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix image preview display
|
||||||
|
document.querySelectorAll('.image-preview-container').forEach(container => {
|
||||||
|
// Add iOS-specific styling
|
||||||
|
container.style.webkitOverflowScrolling = 'touch';
|
||||||
|
container.style.maxHeight = '150px';
|
||||||
|
container.style.overflow = 'auto';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix iOS duplication issues
|
||||||
|
function fixIOSDuplication() {
|
||||||
|
// Override duplicateItem function
|
||||||
|
if (typeof duplicateItem !== 'undefined') {
|
||||||
|
const originalDuplicateItem = duplicateItem;
|
||||||
|
|
||||||
|
duplicateItem = function(itemId) {
|
||||||
|
// Use localStorage on iOS instead of sessionStorage
|
||||||
|
const useFunction = originalDuplicateItem;
|
||||||
|
|
||||||
|
useFunction(itemId);
|
||||||
|
|
||||||
|
// Additional iOS-specific handling
|
||||||
|
const checkForSessionData = setInterval(() => {
|
||||||
|
const sessionData = sessionStorage.getItem('duplicateItemData');
|
||||||
|
if (sessionData) {
|
||||||
|
// Copy from sessionStorage to localStorage
|
||||||
|
try {
|
||||||
|
localStorage.setItem('duplicateItemData', sessionData);
|
||||||
|
localStorage.setItem('duplicateItemTimestamp', Date.now().toString());
|
||||||
|
console.log('Copied duplicate data to localStorage for iOS reliability');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to copy to localStorage:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(checkForSessionData);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Clear interval after 3 seconds regardless
|
||||||
|
setTimeout(() => clearInterval(checkForSessionData), 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix prefill function
|
||||||
|
if (typeof prefillFormWithDuplicateData !== 'undefined') {
|
||||||
|
const originalPrefill = prefillFormWithDuplicateData;
|
||||||
|
|
||||||
|
prefillFormWithDuplicateData = function() {
|
||||||
|
// Try to get data from localStorage first
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
const localData = localStorage.getItem('duplicateItemData');
|
||||||
|
if (localData) {
|
||||||
|
data = JSON.parse(localData);
|
||||||
|
localStorage.removeItem('duplicateItemData');
|
||||||
|
localStorage.removeItem('duplicateItemTimestamp');
|
||||||
|
console.log('Using duplicate data from localStorage');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error accessing localStorage:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got data from localStorage, manually populate the form
|
||||||
|
if (data) {
|
||||||
|
console.log('Manually populating form with iOS optimization');
|
||||||
|
|
||||||
|
// Basic fields
|
||||||
|
document.getElementById('name').value = data.name || '';
|
||||||
|
document.getElementById('beschreibung').value = data.description || '';
|
||||||
|
|
||||||
|
// Handle filters with a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
// Filter 1
|
||||||
|
if (data.filter1 && Array.isArray(data.filter1)) {
|
||||||
|
data.filter1.forEach((val, idx) => {
|
||||||
|
const field = document.getElementById(`filter1-${idx+1}`);
|
||||||
|
if (field && val) field.value = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter 2
|
||||||
|
if (data.filter2 && Array.isArray(data.filter2)) {
|
||||||
|
data.filter2.forEach((val, idx) => {
|
||||||
|
const field = document.getElementById(`filter2-${idx+1}`);
|
||||||
|
if (field && val) field.value = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter 3
|
||||||
|
if (data.filter3 && Array.isArray(data.filter3)) {
|
||||||
|
data.filter3.forEach((val, idx) => {
|
||||||
|
const field = document.getElementById(`filter3-${idx+1}`);
|
||||||
|
if (field && val) field.value = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Location
|
||||||
|
setTimeout(() => {
|
||||||
|
const location = document.getElementById('ort');
|
||||||
|
if (location && data.location) location.value = data.location;
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
// Year and cost
|
||||||
|
const year = document.getElementById('anschaffungsjahr');
|
||||||
|
if (year && data.year) year.value = data.year;
|
||||||
|
|
||||||
|
const cost = document.getElementById('anschaffungskosten');
|
||||||
|
if (cost && data.cost) cost.value = data.cost;
|
||||||
|
|
||||||
|
// Handle images - limited for iOS
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
if (form && data.images && Array.isArray(data.images)) {
|
||||||
|
// Add hidden field to indicate duplication
|
||||||
|
const isDuplicatingField = document.createElement('input');
|
||||||
|
isDuplicatingField.type = 'hidden';
|
||||||
|
isDuplicatingField.name = 'is_duplicating';
|
||||||
|
isDuplicatingField.value = 'true';
|
||||||
|
form.appendChild(isDuplicatingField);
|
||||||
|
|
||||||
|
// Only use first 3 images on iOS for performance
|
||||||
|
const limitedImages = data.images.slice(0, 3);
|
||||||
|
|
||||||
|
limitedImages.forEach(imageName => {
|
||||||
|
const imageField = document.createElement('input');
|
||||||
|
imageField.type = 'hidden';
|
||||||
|
imageField.name = 'duplicate_images';
|
||||||
|
imageField.value = imageName;
|
||||||
|
form.appendChild(imageField);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a message about limited images
|
||||||
|
if (data.images.length > 3) {
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.style.margin = '10px 0';
|
||||||
|
messageDiv.style.padding = '10px';
|
||||||
|
messageDiv.style.backgroundColor = '#fff3cd';
|
||||||
|
messageDiv.style.borderRadius = '5px';
|
||||||
|
messageDiv.style.color = '#856404';
|
||||||
|
messageDiv.textContent = `Aus Leistungsgründen werden nur ${limitedImages.length} von ${data.images.length} Bildern verwendet`;
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('images');
|
||||||
|
if (fileInput && fileInput.parentNode) {
|
||||||
|
fileInput.parentNode.appendChild(messageDiv);
|
||||||
|
} else {
|
||||||
|
form.appendChild(messageDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use original function as fallback
|
||||||
|
originalPrefill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply iOS-specific CSS fixes
|
||||||
|
function applyIOSCSSFixes() {
|
||||||
|
// Create a style element
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
/* Improve touch targets for iOS */
|
||||||
|
button, select, input[type="submit"], .ausleihen, .edit-button,
|
||||||
|
.delete-button, .duplicate-button, .details-button {
|
||||||
|
min-height: 44px !important;
|
||||||
|
padding: 10px 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve scrolling on iOS */
|
||||||
|
.items-container, .filter-options, .modal-content {
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent zoom on inputs */
|
||||||
|
input, select, textarea {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix iOS image display */
|
||||||
|
.item-image {
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve form fields on iOS */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Mobile Compatibility Utilities
|
||||||
|
*
|
||||||
|
* This module provides utility functions for better mobile device compatibility,
|
||||||
|
* especially for iOS devices where certain browser APIs have limited support.
|
||||||
|
*
|
||||||
|
* Copyright 2025 Maximilian Gründinger
|
||||||
|
* Licensed under the Apache License, Version 2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Detect mobile devices including tablets
|
||||||
|
function isMobileDevice() {
|
||||||
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specifically detect iOS devices
|
||||||
|
function isIOSDevice() {
|
||||||
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile-compatible file tracker to replace DataTransfer API
|
||||||
|
class MobileCompatFileTracker {
|
||||||
|
constructor() {
|
||||||
|
this.files = [];
|
||||||
|
this.removedIndices = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add files from a FileList
|
||||||
|
addFiles(fileList) {
|
||||||
|
// Convert FileList to Array for easier handling
|
||||||
|
Array.from(fileList).forEach(file => {
|
||||||
|
this.files.push(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a file by index
|
||||||
|
removeFile(index) {
|
||||||
|
if (index >= 0 && index < this.files.length) {
|
||||||
|
this.removedIndices.add(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all non-removed files
|
||||||
|
getFiles() {
|
||||||
|
return this.files.filter((_, index) => !this.removedIndices.has(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to FormData for upload
|
||||||
|
appendToFormData(formData, fieldName) {
|
||||||
|
this.getFiles().forEach(file => {
|
||||||
|
formData.append(fieldName, file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all files
|
||||||
|
clear() {
|
||||||
|
this.files = [];
|
||||||
|
this.removedIndices.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile optimized image preview generator
|
||||||
|
class ImagePreviewGenerator {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.maxWidth = options.maxWidth || 800;
|
||||||
|
this.maxHeight = options.maxHeight || 600;
|
||||||
|
this.quality = options.quality || 0.8;
|
||||||
|
this.maxPreviewsOnMobile = options.maxPreviewsOnMobile || 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create optimized image preview element
|
||||||
|
createPreview(file, index) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!file) {
|
||||||
|
reject(new Error('No file provided'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVideo = file.type.startsWith('video/');
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
this._createVideoPreview(file, index).then(resolve).catch(reject);
|
||||||
|
} else if (file.type.startsWith('image/')) {
|
||||||
|
this._createImagePreview(file, index).then(resolve).catch(reject);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Unsupported file type'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create image preview with optimization for mobile
|
||||||
|
_createImagePreview(file, index) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
// For mobile, we'll compress the image before creating the preview
|
||||||
|
if (isMobileDevice()) {
|
||||||
|
this._compressImage(e.target.result)
|
||||||
|
.then(compressedDataUrl => {
|
||||||
|
const previewElement = this._createPreviewElement(compressedDataUrl, file.name, index, false);
|
||||||
|
resolve(previewElement);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
// Fallback to original if compression fails
|
||||||
|
console.warn('Image compression failed, using original:', err);
|
||||||
|
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
|
||||||
|
resolve(previewElement);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For desktop, use the original image
|
||||||
|
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
|
||||||
|
resolve(previewElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create video preview with thumbnail generation
|
||||||
|
_createVideoPreview(file, index) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const videoUrl = URL.createObjectURL(file);
|
||||||
|
const video = document.createElement('video');
|
||||||
|
|
||||||
|
video.src = videoUrl;
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
// Create preview element
|
||||||
|
const previewElement = this._createPreviewElement(videoUrl, file.name, index, true);
|
||||||
|
resolve(previewElement);
|
||||||
|
|
||||||
|
// Revoke object URL after a delay to ensure it's loaded
|
||||||
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onerror = () => {
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
reject(new Error('Failed to load video'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set a timeout in case the video never loads
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!video.videoWidth) {
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
reject(new Error('Video load timeout'));
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTML element for preview
|
||||||
|
_createPreviewElement(src, fileName, index, isVideo) {
|
||||||
|
const previewDiv = document.createElement('div');
|
||||||
|
previewDiv.className = 'image-preview-item';
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.src = src;
|
||||||
|
video.controls = true;
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.style.maxWidth = '150px';
|
||||||
|
video.style.maxHeight = '150px';
|
||||||
|
video.style.objectFit = 'cover';
|
||||||
|
previewDiv.appendChild(video);
|
||||||
|
} else {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = src;
|
||||||
|
img.alt = fileName;
|
||||||
|
img.style.maxWidth = '150px';
|
||||||
|
img.style.maxHeight = '150px';
|
||||||
|
img.style.objectFit = 'cover';
|
||||||
|
previewDiv.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlsDiv = document.createElement('div');
|
||||||
|
controlsDiv.className = 'image-controls';
|
||||||
|
|
||||||
|
const removeButton = document.createElement('button');
|
||||||
|
removeButton.type = 'button';
|
||||||
|
removeButton.textContent = 'Entfernen';
|
||||||
|
removeButton.style.background = '#dc3545';
|
||||||
|
removeButton.style.color = 'white';
|
||||||
|
removeButton.style.border = 'none';
|
||||||
|
removeButton.style.padding = '5px 10px';
|
||||||
|
removeButton.style.borderRadius = '3px';
|
||||||
|
removeButton.style.cursor = 'pointer';
|
||||||
|
removeButton.style.marginLeft = '10px';
|
||||||
|
removeButton.dataset.index = index;
|
||||||
|
removeButton.addEventListener('click', function() {
|
||||||
|
const indexToRemove = parseInt(this.dataset.index, 10);
|
||||||
|
// We'll define removeImagePreview elsewhere
|
||||||
|
if (typeof window.removeImagePreview === 'function') {
|
||||||
|
window.removeImagePreview(indexToRemove);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
controlsDiv.appendChild(removeButton);
|
||||||
|
previewDiv.appendChild(controlsDiv);
|
||||||
|
|
||||||
|
return previewDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress image for mobile devices
|
||||||
|
_compressImage(dataUrl) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
|
||||||
|
// Scale down if needed
|
||||||
|
if (width > this.maxWidth || height > this.maxHeight) {
|
||||||
|
const ratio = Math.min(this.maxWidth / width, this.maxHeight / height);
|
||||||
|
width *= ratio;
|
||||||
|
height *= ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Get compressed data URL
|
||||||
|
const compressedDataUrl = canvas.toDataURL('image/jpeg', this.quality);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
|
||||||
|
resolve(compressedDataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => reject(new Error('Failed to load image for compression'));
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile-optimized debounce function for event handlers
|
||||||
|
function debounce(func, wait = 300) {
|
||||||
|
let timeout;
|
||||||
|
return function(...args) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile-safe promise-based setTimeout
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log mobile-specific issues to console or server
|
||||||
|
function logMobileIssue(action, error) {
|
||||||
|
if (isMobileDevice()) {
|
||||||
|
const diagnosticInfo = {
|
||||||
|
action: action,
|
||||||
|
error: error.message,
|
||||||
|
browser: navigator.userAgent,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
memoryInfo: performance?.memory ? {
|
||||||
|
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
|
||||||
|
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
||||||
|
usedJSHeapSize: performance.memory.usedJSHeapSize
|
||||||
|
} : 'Not available'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error('Mobile Issue:', diagnosticInfo);
|
||||||
|
|
||||||
|
// Optionally send to server
|
||||||
|
fetch('/log_mobile_issue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(diagnosticInfo)
|
||||||
|
}).catch(() => {
|
||||||
|
// Silently fail if logging fails
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!--
|
||||||
|
Copyright 2025 Maximilian Gründinger
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<title>{% block title %}Admin Inventarsystem{% endblock %}</title>
|
||||||
|
{% block head %}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--c-bg:#f4f6f9;--c-bg-alt:#ffffff;--c-surface:#ffffff;--c-surface-alt:#f1f5f9;--c-border:#d9dde3;--c-border-strong:#b7bcc4;--c-text:#1f2933;--c-text-soft:#4b5563;--c-text-faint:#6b7280;--c-accent:#2563eb;--c-accent-hover:#1d4ed8;--c-accent-active:#1942b3;--c-success:#059669;--c-success-hover:#047857;--c-danger:#dc2626;--c-danger-hover:#b91c1c;--c-warning:#d97706;--c-focus-ring:0 0 0 3px rgba(37,99,235,.35);--c-shadow-sm:0 1px 3px rgba(0,0,0,0.08);--c-shadow-md:0 4px 12px rgba(0,0,0,0.12);--c-shadow-lg:0 8px 28px rgba(0,0,0,0.18);--c-scroll-track:#e5e7eb;--c-scroll-thumb:#c0c6ce;--c-scroll-thumb-hover:#9aa2ac;--c-badge-bg:#eef2ff;--c-badge-text:#3730a3;--c-code-bg:#f8fafc;--c-overlay:rgba(17,24,39,0.45);--c-link:#2563eb;--c-link-hover:#1d4ed8;--radius-sm:6px;--radius-md:10px;--radius-lg:14px;--transition:.18s cubic-bezier(.4,0,.2,1)
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark){:root{--c-bg:#0d1117;--c-bg-alt:#101723;--c-surface:#121c29;--c-surface-alt:#182432;--c-border:#2a3a49;--c-border-strong:#3a4d5e;--c-text:#e3e9f1;--c-text-soft:#c2ccd6;--c-text-faint:#94a3b4;--c-accent:#3b82f6;--c-accent-hover:#2563eb;--c-accent-active:#1d4ed8;--c-success:#10b981;--c-success-hover:#059669;--c-danger:#ef4444;--c-danger-hover:#dc2626;--c-warning:#f59e0b;--c-focus-ring:0 0 0 3px rgba(59,130,246,.45);--c-shadow-sm:0 1px 4px rgba(0,0,0,0.55);--c-shadow-md:0 4px 18px rgba(0,0,0,0.55);--c-shadow-lg:0 10px 38px rgba(0,0,0,0.6);--c-scroll-track:#1c2632;--c-scroll-thumb:#314254;--c-scroll-thumb-hover:#44576a;--c-badge-bg:#1e293b;--c-badge-text:#c7d2fe;--c-code-bg:#182432;--c-overlay:rgba(11,18,32,0.62);--c-link:#60a5fa;--c-link-hover:#3b82f6}}
|
||||||
|
html{box-sizing:border-box}*,*::before,*::after{box-sizing:inherit}body{min-height:100svh;margin:0;font-family:Arial,system-ui,sans-serif;background:var(--c-bg);color:var(--c-text);padding:0 0 30px 0;transition:background .3s,color .3s}
|
||||||
|
.container:not(.container-fluid){width:min(100% - 2rem,1200px);margin-inline:auto;padding-top:20px}
|
||||||
|
.fit-content{width:fit-content;max-width:100%}.fit-content-height{height:fit-content;max-height:100%}
|
||||||
|
:where(p,h1,h2,h3,h4,h5,h6,li){overflow-wrap:anywhere;word-break:normal}img,video,canvas,svg{max-width:100%;height:auto}
|
||||||
|
.table-wrap{max-width:100%;overflow-x:auto}table.table-fit{width:max-content;max-width:100%;border-collapse:collapse}table.table-fit th,table.table-fit td{padding:.5rem .75rem;text-align:left}
|
||||||
|
input,select,textarea,button{max-width:100%}
|
||||||
|
/* Navbar */
|
||||||
|
.navbar{box-shadow:0 2px 5px rgba(0,0,0,.1);border-bottom:1px solid var(--c-border);background:linear-gradient(90deg,#111827,#142033)}.navbar-brand{font-weight:600;font-size:1.3rem}
|
||||||
|
/* High contrast on dark navbar in all modes */
|
||||||
|
.navbar-dark .navbar-brand{color:#f3f4f6}
|
||||||
|
.navbar-dark .nav-link{color:#e5e7eb}
|
||||||
|
.navbar-dark .nav-link:hover{color:#ffffff}
|
||||||
|
.admin-nav-section{margin-left:auto;display:flex;gap:15px}
|
||||||
|
.admin-nav-section .nav-item{background:var(--c-success);color:#fff;padding:5px 10px;border-radius:6px;text-decoration:none;transition:var(--transition);font-weight:600;font-size:.85rem}
|
||||||
|
.admin-nav-section .nav-item:hover{background:var(--c-success-hover);transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.18)}
|
||||||
|
.navbar-nav{align-items:center}.navbar-nav .nav-item{margin-right:5px}.navbar-nav .nav-item:last-child{margin-right:0}
|
||||||
|
/* Flash messages */
|
||||||
|
.flashes{margin:20px 0}.flash{padding:12px 20px;margin-bottom:15px;border-radius:8px;font-weight:500;border-left:6px solid transparent;background:var(--c-surface);border:1px solid var(--c-border)}
|
||||||
|
.flash.success{background:rgba(16,185,129,0.12);border-left-color:var(--c-success);color:var(--c-success)}
|
||||||
|
.flash.error{background:rgba(239,68,68,0.15);border-left-color:var(--c-danger);color:var(--c-danger)}
|
||||||
|
.flash.info{background:rgba(59,130,246,0.12);border-left-color:var(--c-accent);color:var(--c-accent)}
|
||||||
|
/* Dropdown */
|
||||||
|
.dropdown-menu{box-shadow:0 8px 24px rgba(0,0,0,.15);border:1px solid var(--c-border);border-radius:10px;padding:8px 0;min-width:200px;margin-top:6px;background:var(--c-surface);color:var(--c-text)}
|
||||||
|
.dropdown-item{padding:10px 16px;font-size:.9rem;color:var(--c-text-soft);transition:var(--transition)}
|
||||||
|
.dropdown-item:hover,.dropdown-item:focus{background:var(--c-surface-alt);color:var(--c-text);transform:translateX(2px)}
|
||||||
|
.dropdown-divider{margin:6px 0;border-color:var(--c-border)}
|
||||||
|
/* Logout */
|
||||||
|
.logout-btn{border-radius:999px;padding:.375rem .85rem;font-weight:600;letter-spacing:.015em;line-height:1.25;transition:var(--transition)}
|
||||||
|
.navbar-dark .logout-btn.btn-outline-danger{color:#ffc9ce;border-color:rgba(220,53,69,.6)}
|
||||||
|
.navbar-dark .logout-btn.btn-outline-danger:hover{color:#fff;background:rgba(220,53,69,.9);border-color:rgba(220,53,69,1);transform:translateY(-1px);box-shadow:0 6px 16px rgba(220,53,69,.25)}
|
||||||
|
/* Scrollbars */
|
||||||
|
*::-webkit-scrollbar{width:12px;height:12px}*::-webkit-scrollbar-track{background:var(--c-scroll-track)}*::-webkit-scrollbar-thumb{background:var(--c-scroll-thumb);border-radius:20px;border:2px solid var(--c-scroll-track)}*::-webkit-scrollbar-thumb:hover{background:var(--c-scroll-thumb-hover)}
|
||||||
|
/* Links */
|
||||||
|
a{color:var(--c-link);text-decoration:none}a:hover{color:var(--c-link-hover)}
|
||||||
|
/* Generic surfaces */
|
||||||
|
.card,.content,.form-card,.item-card,.upload-card{background:var(--c-surface);border:1px solid var(--c-border);box-shadow:var(--c-shadow-sm);color:var(--c-text);border-radius:var(--radius-md)}
|
||||||
|
.item-card:hover{border-color:var(--c-border-strong);box-shadow:var(--c-shadow-md)}
|
||||||
|
.badge{background:var(--c-badge-bg);color:var(--c-badge-text);border:1px solid var(--c-border);font-weight:600;border-radius:var(--radius-sm);padding:.25rem .55rem;font-size:.7rem;letter-spacing:.03em}
|
||||||
|
/* Buttons */
|
||||||
|
.btn,.action-button,.run-backup,.update-system,.actions a.download,.actions a.restore,.upload-actions .primary{background:var(--c-accent);color:#fff !important;border:1px solid var(--c-accent-hover);transition:var(--transition);font-weight:600}
|
||||||
|
.btn:hover,.action-button:hover,.run-backup:hover,.update-system:hover,.actions a.download:hover,.actions a.restore:hover,.upload-actions .primary:hover{background:var(--c-accent-hover)}
|
||||||
|
.btn-danger,.danger-button{background:var(--c-danger);border-color:var(--c-danger-hover)}.btn-danger:hover,.danger-button:hover{background:var(--c-danger-hover)}
|
||||||
|
.ghost{background:transparent;border:1px solid var(--c-border);color:var(--c-text)}.ghost:hover{background:var(--c-surface-alt)}
|
||||||
|
/* Forms */
|
||||||
|
input,select,textarea{background:var(--c-surface-alt);border:1px solid var(--c-border);color:var(--c-text);border-radius:var(--radius-sm);padding:.45rem .65rem}
|
||||||
|
input:focus,select:focus,textarea:focus{border-color:var(--c-accent);box-shadow:var(--c-focus-ring);outline:none}
|
||||||
|
/* Modals */
|
||||||
|
.item-modal{background:var(--c-overlay)}.modal-content{background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);box-shadow:var(--c-shadow-lg)}
|
||||||
|
.close-modal{color:var(--c-text-faint)}.close-modal:hover{color:var(--c-text)}
|
||||||
|
/* Status pill */
|
||||||
|
.status-pill{background:var(--c-surface);border:1px solid var(--c-border);color:var(--c-text-soft);padding:.25rem .6rem;border-radius:999px;font-weight:600}
|
||||||
|
.status-pill.on{background:rgba(16,185,129,0.15);border-color:var(--c-success);color:var(--c-success)}
|
||||||
|
.status-pill.off{background:rgba(239,68,68,0.18);border-color:var(--c-danger);color:var(--c-danger)}
|
||||||
|
/* Focus */
|
||||||
|
:focus-visible{outline:none;box-shadow:var(--c-focus-ring)}
|
||||||
|
/* Utility */
|
||||||
|
.text-info{opacity:.85}
|
||||||
|
@media (max-width:768px){.navbar-nav .dropdown-menu{border:none;box-shadow:none;background:rgba(17,24,39,.92);margin-left:15px;margin-top:5px;border-radius:10px}.navbar-nav .dropdown-item{color:#fff}.navbar-nav .dropdown-item:hover,.navbar-nav .dropdown-item:focus{background:rgba(255,255,255,.08);color:#fff;transform:none}.nav-item.dropdown .dropdown-toggle{padding:8px 12px}.dropdown-toggle::after{margin-left:8px}.dropdown-menu-end{background:var(--c-surface);border:1px solid var(--c-border)}.dropdown-menu-end .dropdown-item{color:var(--c-text-soft)}.dropdown-menu-end .dropdown-item:hover{background:var(--c-surface-alt);color:var(--c-text)}}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('default') }}">Inventarsystem</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarContent">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('default') }}">Home</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex ms-auto">
|
||||||
|
{% if 'username' in session %}
|
||||||
|
<a id="logout" class="btn btn-outline-danger btn-sm logout-btn" href="{{ url_for('logout') }}" aria-label="Logout">Logout</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flashes">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<!-- Mobile compatibility scripts -->
|
||||||
|
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<!--
|
||||||
|
Copyright 2025 Maximilian Gründinger
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Login{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h2 class="text-center">Login</h2>
|
||||||
|
<form method="POST" action="{{ url_for('login') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">TOTP</label>
|
||||||
|
<div class="password-input-container">
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
<button type="button" class="password-toggle-btn" id="togglePassword" aria-label="Show password">
|
||||||
|
<input type="checkbox" checked="checked">
|
||||||
|
<svg class="eye" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 576 512"><path d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"></path></svg>
|
||||||
|
<svg class="eye-slash" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512"><path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const togglePassword = document.getElementById('togglePassword');
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
const passwordContainer = document.querySelector('.password-input-container');
|
||||||
|
|
||||||
|
togglePassword.addEventListener('click', function() {
|
||||||
|
// Toggle the password field type
|
||||||
|
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
|
||||||
|
passwordInput.setAttribute('type', type);
|
||||||
|
|
||||||
|
// Toggle the visual state of the container
|
||||||
|
passwordContainer.classList.toggle('password-visible');
|
||||||
|
|
||||||
|
//Toggle visability of eye according to state
|
||||||
|
|
||||||
|
// Update aria-label for accessibility
|
||||||
|
const isVisible = type === 'text';
|
||||||
|
togglePassword.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.password-input-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
#togglePassword {
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#togglePassword {
|
||||||
|
--color: #a5a5b0;
|
||||||
|
--size: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--size);
|
||||||
|
user-select: none;
|
||||||
|
fill: var(--color);
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#togglePassword .eye-slash {
|
||||||
|
position: absolute;
|
||||||
|
animation: keyframes-fill .5s;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#togglePassword .eye {
|
||||||
|
position: absolute;
|
||||||
|
animation: keyframes-fill .5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ On check event ------ */
|
||||||
|
#togglePassword input:checked ~ .eye-slash {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#togglePassword input:checked ~ .eye {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ Hide the default checkbox ------ */
|
||||||
|
#togglePassword input {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ Animation ------ */
|
||||||
|
@keyframes keyframes-fill {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -1,2 +1,53 @@
|
|||||||
<a>This is a Test If this works it would be great!</a>
|
{% extends "base.html" %}
|
||||||
<p>Version: {{ version }}</p>
|
|
||||||
|
{% block title %}Keys & HWIDs{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1">All Keys</h2>
|
||||||
|
<p class="text-muted mb-0">Overview of license keys and bound HWIDs.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('generate_new') }}">
|
||||||
|
<button type="submit" class="btn btn-success">Generate New Key</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table table-striped table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">User ID</th>
|
||||||
|
<th scope="col">License Key</th>
|
||||||
|
<th scope="col">HWID</th>
|
||||||
|
<th scope="col" class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in keys %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.user_id }}</td>
|
||||||
|
<td><code>{{ item.license_key }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if item.hwid_uuid %}
|
||||||
|
<code>{{ item.hwid_uuid }}</code>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-warning">Not assigned</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<form method="POST" action="{{ url_for('remove_key', user_id=item.user_id) }}" onsubmit="return confirm('Remove key for user {{ item.user_id }}?');">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted py-4">No keys found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -46,5 +46,16 @@ def new_key() -> str:
|
|||||||
register_new_Key(data)
|
register_new_Key(data)
|
||||||
return generated_key
|
return generated_key
|
||||||
|
|
||||||
|
|
||||||
|
def remove_key(user_id: str) -> bool:
|
||||||
|
data = load_file()
|
||||||
|
filtered = [item for item in data if str(item.get("user_id", "")) != str(user_id)]
|
||||||
|
|
||||||
|
if len(filtered) == len(data):
|
||||||
|
return False
|
||||||
|
|
||||||
|
register_new_Key(filtered)
|
||||||
|
return True
|
||||||
|
|
||||||
def key_generator():
|
def key_generator():
|
||||||
return secrets.token_urlsafe(32)
|
return secrets.token_urlsafe(32)
|
||||||
@@ -15,7 +15,7 @@ if ! command -v patchelf >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pip install -U nuitka ordered-set zstandard flask cryptography
|
pip install -U nuitka ordered-set zstandard flask cryptography pyotp qrcode
|
||||||
python - <<'PY'
|
python - <<'PY'
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
@@ -40,7 +40,7 @@ if settings_path.is_file():
|
|||||||
version_file.write_text(f"{version}\n", encoding="utf-8")
|
version_file.write_text(f"{version}\n", encoding="utf-8")
|
||||||
print(f"Prepared {version_file} with version: {version}")
|
print(f"Prepared {version_file} with version: {version}")
|
||||||
PY
|
PY
|
||||||
python -m nuitka --standalone --follow-imports --include-data-dir=Code/templates=templates --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=build --remove-output Code/main.py
|
python -m nuitka --standalone --follow-imports --include-data-dir=Code/templates=templates --include-data-dir=Code/static=static --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=build --remove-output Code/main.py
|
||||||
if [[ -x "./build/main.dist/main.bin" ]]; then
|
if [[ -x "./build/main.dist/main.bin" ]]; then
|
||||||
./build/main.dist/main.bin
|
./build/main.dist/main.bin
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user