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
|
||||
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 pyotp
|
||||
import qrcode
|
||||
|
||||
def _resolve_template_dir() -> Path:
|
||||
candidates = [
|
||||
@@ -16,9 +18,10 @@ def _resolve_template_dir() -> Path:
|
||||
|
||||
return candidates[0]
|
||||
|
||||
totp_key = "Hsdfisdf4n34234dfiseLoasjfj3asnnvhxbbfgrzzuewwndcodrweokyn"
|
||||
|
||||
app = Flask(__name__, template_folder=str(_resolve_template_dir()))
|
||||
|
||||
app.secret_key = "Test123"
|
||||
|
||||
def _read_app_version() -> str:
|
||||
candidates = [
|
||||
@@ -33,6 +36,13 @@ def _read_app_version() -> str:
|
||||
|
||||
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()
|
||||
|
||||
@@ -50,9 +60,70 @@ def validate__information():
|
||||
else:
|
||||
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("/")
|
||||
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():
|
||||
|
||||
@@ -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>
|
||||
<p>Version: {{ version }}</p>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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)
|
||||
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():
|
||||
return secrets.token_urlsafe(32)
|
||||
@@ -15,7 +15,7 @@ if ! command -v patchelf >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pip install -U nuitka ordered-set zstandard flask cryptography
|
||||
pip install -U nuitka ordered-set zstandard flask cryptography pyotp qrcode
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
import json
|
||||
@@ -40,7 +40,7 @@ if settings_path.is_file():
|
||||
version_file.write_text(f"{version}\n", encoding="utf-8")
|
||||
print(f"Prepared {version_file} with version: {version}")
|
||||
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
|
||||
./build/main.dist/main.bin
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user