Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6e3db9b4a | |||
| 7e5ee7b5ea | |||
| 2528e79895 | |||
| 542caa520f | |||
| 5136e40587 | |||
| c62b2b553d | |||
| 8783f97a09 | |||
| 43e09b41f1 | |||
| 84257dc289 | |||
| c4b3850369 | |||
| 28e9487fe1 | |||
| fe075938d7 | |||
| 0ef928efd8 | |||
| 1299140823 |
+57
-42
@@ -3828,7 +3828,10 @@ def api_item_detail(item_id):
|
||||
"""
|
||||
|
||||
client.close()
|
||||
return detail_html, 200
|
||||
return jsonify({
|
||||
'html': detail_html,
|
||||
'images': item.get('Images', item.get('Bilder', []))
|
||||
}), 200
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching item detail: {e}")
|
||||
return jsonify({'error': 'An error occurred while fetching the item detail'}), 500
|
||||
@@ -4431,10 +4434,19 @@ def student_card_class_barcode_download():
|
||||
db = client[cfg.MONGODB_DB]
|
||||
student_cards = db['student_cards']
|
||||
|
||||
class_cards = list(student_cards.find({'Klasse': class_name}).sort('AusweisId', 1))
|
||||
class_cards = [_decrypt_student_card_doc(card) for card in class_cards]
|
||||
# 1. Alle Ausweise laden
|
||||
all_cards = list(student_cards.find().sort('AusweisId', 1))
|
||||
client.close()
|
||||
|
||||
# 2. Alle entschlüsseln
|
||||
decrypted_cards = [_decrypt_student_card_doc(card) for card in all_cards]
|
||||
|
||||
# 3. Im Speicher nach der Klartext-Klasse filtern
|
||||
class_cards = [
|
||||
card for card in decrypted_cards
|
||||
if card.get('Klasse', '').strip() == class_name
|
||||
]
|
||||
|
||||
if not class_cards:
|
||||
flash(f'Keine Ausweise für die Klasse "{class_name}" gefunden.', 'error')
|
||||
return redirect(url_for('student_cards_admin'))
|
||||
@@ -5472,7 +5484,7 @@ def upload_item():
|
||||
fs = get_gridfs()
|
||||
|
||||
if cfg.MODULES.is_enabled('library') and sanitize_form_value(request.form.get('item_type_input', '')) != "other":
|
||||
success_redirect_endpoint = 'library'
|
||||
success_redirect_endpoint = 'library_view'
|
||||
else:
|
||||
success_redirect_endpoint = 'home_admin'
|
||||
|
||||
@@ -5816,7 +5828,9 @@ def upload_item():
|
||||
|
||||
if item_id:
|
||||
success_msg = f'Element wurde erfolgreich hinzugefügt ({len(created_item_ids)} erstellt)'
|
||||
fs = get_gridfs() # Deine GridFS Verbindung
|
||||
|
||||
it.cleanup_orphaned_images(fs, dry_run=True)
|
||||
if upload_mode == 'library':
|
||||
try:
|
||||
_append_audit_event_standalone(
|
||||
@@ -9949,25 +9963,28 @@ def fetch_book_info(isbn):
|
||||
app.logger.error(f"Error fetching book data: {e}")
|
||||
return jsonify({"error": f"Failed to fetch book information"}), 500
|
||||
|
||||
|
||||
@app.route('/download_book_cover', methods=['POST'])
|
||||
def download_book_cover():
|
||||
"""
|
||||
API endpoint to download and save a book cover image from URL
|
||||
API endpoint to download a book cover image from URL
|
||||
and save it directly to MongoDB GridFS.
|
||||
"""
|
||||
if 'username' not in session:
|
||||
return jsonify({"error": "Not authorized"}), 403
|
||||
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
|
||||
if not current_permissions['actions'].get('can_insert', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library_view'))
|
||||
return jsonify({"error": "Ihnen fehlen die nötigen Berechtigungen."}), 403
|
||||
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
|
||||
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
image_url = data.get('url')
|
||||
|
||||
|
||||
if not image_url:
|
||||
return jsonify({"error": "No image URL provided"}), 400
|
||||
|
||||
@@ -9975,71 +9992,69 @@ def download_book_cover():
|
||||
if parsed_url.scheme != 'https' or not parsed_url.netloc:
|
||||
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
|
||||
|
||||
# 2. SSRF Protection: Strict Allowlist Check
|
||||
# if parsed_url.netloc not in ALLOWED_COVER_DOMAINS:
|
||||
# return jsonify({"error": "Target host is not an allowed book cover provider"}), 403
|
||||
|
||||
# Download the image (allow_redirects=False prevents redirecting to internal IPs)
|
||||
response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False)
|
||||
|
||||
|
||||
if response.status_code != 200:
|
||||
return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400
|
||||
|
||||
# Check content type
|
||||
|
||||
content_type = response.headers.get('content-type', '')
|
||||
allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']
|
||||
|
||||
|
||||
if not any(allowed_type in content_type.lower() for allowed_type in allowed_types):
|
||||
return jsonify({
|
||||
"error": f"Nicht unterstütztes Bildformat: {content_type}. Erlaubte Formate: JPG, JPEG, PNG, GIF"
|
||||
}), 400
|
||||
|
||||
# Check content length header
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length:
|
||||
try:
|
||||
if int(content_length) > 5 * 1024 * 1024:
|
||||
return jsonify({"error": "Image is too large"}), 413
|
||||
return jsonify({"error": "Image is too large (max 5MB)"}), 413
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Generate a fully unique filename
|
||||
|
||||
unique_id = str(uuid.uuid4())
|
||||
timestamp = time.strftime("%Y%m%d%H%M%S")
|
||||
|
||||
|
||||
extension = '.jpg' # default
|
||||
if 'image/png' in content_type.lower():
|
||||
extension = '.png'
|
||||
elif 'image/gif' in content_type.lower():
|
||||
extension = '.gif'
|
||||
|
||||
|
||||
filename = f"book_cover_{unique_id}_{timestamp}{extension}"
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
|
||||
# Save image in chunks (prevents memory exhaustion and enforces size limits)
|
||||
with open(filepath, 'wb') as f:
|
||||
written = 0
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
written += len(chunk)
|
||||
if written > 5 * 1024 * 1024:
|
||||
# Clean up the partial file before aborting
|
||||
os.remove(filepath)
|
||||
return jsonify({"error": "Image is too large"}), 413
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
image_data = io.BytesIO()
|
||||
written = 0
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
written += len(chunk)
|
||||
if written > 5 * 1024 * 1024:
|
||||
return jsonify({"error": "Image is too large (max 5MB)"}), 413
|
||||
image_data.write(chunk)
|
||||
|
||||
image_data.seek(0)
|
||||
|
||||
fs = get_gridfs()
|
||||
fs.put(
|
||||
image_data,
|
||||
filename=filename,
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"filename": filename,
|
||||
"message": "Image downloaded successfully"
|
||||
"message": "Image downloaded and stored directly in database"
|
||||
})
|
||||
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
app.logger.error(f"Network error downloading book cover: {e}")
|
||||
return jsonify({"error": "Netzwerkfehler beim Herunterladen des Bildes."}), 500
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error downloading book cover: {e}")
|
||||
# Fixed syntax here: Removed the injected HTML that was appended to this line
|
||||
return jsonify({"error": f"Failed to download image"}), 500
|
||||
return jsonify({"error": "Failed to download image"}), 500
|
||||
|
||||
"""
|
||||
@app.route('/proxy_image')
|
||||
def proxy_image():
|
||||
|
||||
@@ -25,6 +25,7 @@ import datetime
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
import Web.modules.inventarsystem.data_protection as dp
|
||||
import logging
|
||||
|
||||
|
||||
def is_library_item(item):
|
||||
@@ -1292,4 +1293,137 @@ def sync_group_codes(primary_obj_id, base_code, individual_codes_list):
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error syncing group codes: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
|
||||
|
||||
def cleanup_orphaned_images(fs, dry_run=True):
|
||||
"""
|
||||
Finds images in GridFS that are no longer referenced by any item
|
||||
and optionally deletes them.
|
||||
|
||||
Supported item fields:
|
||||
- book_cover_image
|
||||
- image
|
||||
- images
|
||||
|
||||
The fields are expected to contain GridFS file ObjectIds.
|
||||
|
||||
:param fs: GridFS instance, e.g. gridfs.GridFS(db)
|
||||
:param dry_run: If True, only reports what would be deleted.
|
||||
If False, actually deletes the files.
|
||||
"""
|
||||
|
||||
logging.info("Starte Cleanup-Skript...")
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
|
||||
try:
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items_collection = db["items"]
|
||||
|
||||
referenced_files = set()
|
||||
|
||||
for item in items_collection.find(
|
||||
{},
|
||||
{
|
||||
"Images": 1
|
||||
}
|
||||
):
|
||||
images = item.get("Images")
|
||||
|
||||
if isinstance(images, list):
|
||||
for img in images:
|
||||
if img:
|
||||
referenced_files.add(img)
|
||||
|
||||
referenced_files.discard(None)
|
||||
|
||||
logging.info(
|
||||
f"{len(referenced_files)} referenzierte GridFS-Dateien gefunden."
|
||||
)
|
||||
|
||||
orphaned_files = []
|
||||
|
||||
for grid_file in fs.find():
|
||||
file_id = grid_file._id
|
||||
filename = grid_file.filename or ""
|
||||
|
||||
if not filename.lower().endswith(
|
||||
(".jpg", ".jpeg", ".png", ".gif", ".webp")
|
||||
):
|
||||
continue
|
||||
|
||||
if file_id not in referenced_files:
|
||||
orphaned_files.append(
|
||||
{
|
||||
"_id": file_id,
|
||||
"filename": filename,
|
||||
"upload_date": grid_file.upload_date,
|
||||
}
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Gefundene verwaiste Bilder: {len(orphaned_files)}"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logging.info(
|
||||
"--- DRY RUN AKTIV - Es wird nichts gelöscht ---"
|
||||
)
|
||||
|
||||
for file in orphaned_files:
|
||||
logging.info(
|
||||
f"Würde löschen: "
|
||||
f"{file['filename']} "
|
||||
f"(ID: {file['_id']}, "
|
||||
f"Hochgeladen: {file['upload_date']})"
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"--- Setze dry_run=False, um physisch zu löschen ---"
|
||||
)
|
||||
|
||||
else:
|
||||
logging.warning("--- LÖSCHVORGANG AKTIV ---")
|
||||
|
||||
deleted_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for file in orphaned_files:
|
||||
try:
|
||||
fs.delete(file["_id"])
|
||||
|
||||
deleted_count += 1
|
||||
|
||||
logging.info(
|
||||
f"Gelöscht: {file['filename']} "
|
||||
f"(ID: {file['_id']})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
|
||||
logging.error(
|
||||
f"Fehler beim Löschen von "
|
||||
f"{file['filename']} "
|
||||
f"(ID: {file['_id']}): {e}"
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Cleanup beendet. "
|
||||
f"{deleted_count} Bilder gelöscht, "
|
||||
f"{failed_count} Fehler."
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"referenced_count": len(referenced_files),
|
||||
"orphaned_count": len(orphaned_files),
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
+170
-119
@@ -135,21 +135,6 @@
|
||||
.library-scan-status.warn { color: #9a6700; }
|
||||
.library-scan-status.error { color: #b42318; }
|
||||
|
||||
.library-scan-reader-wrap {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
max-width: 460px;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid #d9dde4;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.library-scan-reader {
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.library-filter-toggle-btn {
|
||||
padding: 10px 16px;
|
||||
@@ -431,6 +416,66 @@
|
||||
|
||||
.library-scan-reader-wrap { max-width: 100%; }
|
||||
}
|
||||
.library-scan-reader-wrap {
|
||||
display: none;
|
||||
margin-top: 15px;
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background: #000;
|
||||
border: 1px solid #d9dde4;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.library-scan-reader {
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.library-scan-reader video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.library-scan-reader canvas.drawing,
|
||||
.library-scan-reader canvas.drawingBuffer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-gallery-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 15px 0 20px 0; /* Abstand oben und unten unter dem Titel */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 130px; /* Feste Höhe verhindert das Kollabieren im Modal! */
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* Passt das Bild perfekt ein, ohne es zu verzerren */
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="library-table-container" id="libraryTableContainer" data-can-edit="{{ 1 if current_permissions.actions.get('can_edit', False) else 0 }}">
|
||||
@@ -457,7 +502,6 @@
|
||||
🔍 Filter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="library-scan-controls">
|
||||
<select id="scanModeSelect" aria-label="Scan-Modus">
|
||||
<option value="card_only">Nur Ausweis erfassen</option>
|
||||
@@ -475,6 +519,10 @@
|
||||
</label>
|
||||
<button id="manualActionBtn" class="button" type="button" style="margin-left:6px; background:#4f46e5; color:white;">Code verarbeiten</button>
|
||||
</div>
|
||||
<div class="library-scan-reader-wrap" id="scanReaderWrap" style="display: none; margin-top: 15px;">
|
||||
<div class="library-scan-reader" style="width: 100%; max-width: 640px; margin: 0 auto; overflow: hidden; border-radius: 8px; border: 2px solid #ccc;">
|
||||
</div>
|
||||
</div>
|
||||
<div id="filterPanel" class="library-filter-panel">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
@@ -805,71 +853,92 @@
|
||||
deleteLibraryItem(itemId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. CORE SCANNER ROUTING ENGINE (QUAGGA2)
|
||||
// =========================================================================
|
||||
function startScanner(targetCallback) {
|
||||
const readerWrap = document.getElementById('scanReaderWrap');
|
||||
async function startScanner(callback = null) {
|
||||
if (scannerRunning) {
|
||||
console.warn("Scanner is already running. Stopping it first...");
|
||||
await stopScanner();
|
||||
}
|
||||
|
||||
activeScannerCallback = callback;
|
||||
|
||||
const scannerWrap = document.querySelector('.library-scan-reader-wrap');
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
|
||||
activeScannerCallback = targetCallback;
|
||||
// 1. CRITICAL FIX: Make the container visible BEFORE Quagga initializes
|
||||
// Quagga cannot calculate video dimensions if display is 'none'
|
||||
if (scannerWrap) {
|
||||
scannerWrap.style.display = 'block';
|
||||
}
|
||||
|
||||
if (readerWrap) readerWrap.style.display = 'block';
|
||||
setScanStatus('Initializing camera...', 'warn');
|
||||
setScanStatus('Starte Kamera...', 'warn');
|
||||
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
target: document.querySelector('#library-scanner-container'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment"
|
||||
// 2. Initialize Quagga with a slight delay to allow the DOM to render the block
|
||||
setTimeout(() => {
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// This MUST match the inner div where the video should appear
|
||||
target: document.querySelector('.library-scan-reader'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Prefer back camera on mobile
|
||||
}
|
||||
},
|
||||
},
|
||||
decoder: {
|
||||
readers: [
|
||||
"code_128_reader",
|
||||
"ean_reader",
|
||||
"code_39_reader",
|
||||
"upc_reader",
|
||||
"codabar_reader",
|
||||
"i2of5_reader"
|
||||
]
|
||||
}
|
||||
}, function(err) {
|
||||
if (err) {
|
||||
console.error('Scanner start failed:', err);
|
||||
if (readerWrap) readerWrap.style.display = 'none';
|
||||
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
|
||||
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
|
||||
return;
|
||||
}
|
||||
locator: {
|
||||
patchSize: "medium",
|
||||
halfSample: true
|
||||
},
|
||||
decoder: {
|
||||
// Keep only the barcode types you actually use to improve performance
|
||||
readers: ["code_128_reader", "ean_reader", "code_39_reader"]
|
||||
},
|
||||
locate: true
|
||||
}, function(err) {
|
||||
if (err) {
|
||||
// This will finally log the actual error (e.g., NotAllowedError) if permissions fail
|
||||
console.error("Quagga initialization failed:", err);
|
||||
setScanStatus('Kamera-Fehler: ' + (err.name || err), 'error');
|
||||
|
||||
Quagga.start();
|
||||
scannerRunning = true;
|
||||
if (scannerWrap) scannerWrap.style.display = 'none';
|
||||
scannerRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetCallback && toggleBtn) {
|
||||
toggleBtn.textContent = 'Scanner stoppen';
|
||||
}
|
||||
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
|
||||
});
|
||||
Quagga.start();
|
||||
scannerRunning = true;
|
||||
setScanStatus('Scanner bereit.', 'ok');
|
||||
|
||||
if (toggleBtn) {
|
||||
toggleBtn.textContent = 'Scanner stoppen';
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function stopScanner() {
|
||||
async function stopScanner() {
|
||||
if (!scannerRunning) return;
|
||||
|
||||
const readerWrap = document.getElementById('scanReaderWrap');
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
try {
|
||||
Quagga.stop();
|
||||
} catch (e) {
|
||||
console.warn("Error stopping Quagga (it may not have been running):", e);
|
||||
}
|
||||
|
||||
Quagga.stop();
|
||||
scannerRunning = false;
|
||||
activeScannerCallback = null;
|
||||
|
||||
if (readerWrap) readerWrap.style.display = 'none';
|
||||
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
|
||||
setScanStatus('Scanner gestoppt.', 'warn');
|
||||
// Hide the container to free up UI space
|
||||
const scannerWrap = document.querySelector('.library-scan-reader-wrap');
|
||||
if (scannerWrap) {
|
||||
scannerWrap.style.display = 'none';
|
||||
}
|
||||
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.textContent = 'Kamera Scanner'; // Reset button text
|
||||
}
|
||||
}
|
||||
|
||||
async function isStudentCardBarcode(code) {
|
||||
@@ -1288,66 +1357,48 @@
|
||||
detailContent.innerHTML = '<p>Lade Details...</p>';
|
||||
detailModal.style.display = 'flex';
|
||||
|
||||
const item = libraryItems.find(i => i._id === itemId);
|
||||
let mediaHtml = '';
|
||||
|
||||
// Robuste Prüfung: Wir testen gängige Benennungen aus deinem Backend
|
||||
const imageArray = item.Images || item.Bilder || item.images;
|
||||
|
||||
if (item && Array.isArray(imageArray) && imageArray.length > 0) {
|
||||
const imagesHtml = imageArray.map((image, index) => {
|
||||
|
||||
// Dein neuer Code für die exakte Routen-Generierung
|
||||
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ?
|
||||
image :
|
||||
`{{ url_for('uploaded_file', filename='') }}${image}`;
|
||||
|
||||
const thumbnailInfo = item.ThumbnailInfo && item.ThumbnailInfo[index];
|
||||
const isVideo = isVideoFile(image);
|
||||
|
||||
if (isVideo) {
|
||||
const videoSrc = thumbnailInfo && thumbnailInfo.has_thumbnail
|
||||
? thumbnailInfo.thumbnail_url
|
||||
: imageSrc;
|
||||
|
||||
if (thumbnailInfo && thumbnailInfo.has_thumbnail) {
|
||||
return `
|
||||
<div class="video-container" style="position: relative; width: 120px; height: 120px; display: inline-block; margin-right: 15px; margin-bottom: 15px;">
|
||||
<img src="${videoSrc}" alt="${escapeHtml(item.Name || 'Medium')}" class="item-image" style="width: 100%; height: 100%; object-fit: cover; border-radius: 8px; border: 1px solid #ddd;">
|
||||
<div class="video-preview-overlay" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; background: rgba(0,0,0,0.6); border-radius: 50%; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; font-size: 16px;">
|
||||
▶
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
return `<div style="width: 120px; height: 120px; background: #333; color: #fff; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; margin-right: 15px; margin-bottom: 15px;">VIDEO</div>`;
|
||||
}
|
||||
} else {
|
||||
const imageSrcFinal = thumbnailInfo && thumbnailInfo.has_thumbnail
|
||||
? thumbnailInfo.thumbnail_url
|
||||
: imageSrc;
|
||||
|
||||
return `<img src="${imageSrcFinal}" alt="${escapeHtml(item.Name || 'Medium')}" class="item-image" style="width: 120px; height: 120px; object-fit: cover; border-radius: 8px; border: 1px solid #ddd; margin-right: 15px; margin-bottom: 15px;">`;
|
||||
}
|
||||
}).join('');
|
||||
|
||||
mediaHtml = `<div class="detail-gallery-container" style="margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #eee; display: flex; flex-wrap: wrap;">${imagesHtml}</div>`;
|
||||
}
|
||||
|
||||
fetch(`/api/item_detail/${itemId}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Artikeldetails');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(html => {
|
||||
detailContent.innerHTML = mediaHtml + DOMPurify.sanitize(html);
|
||||
.then(data => {
|
||||
detailContent.innerHTML = data.html;
|
||||
|
||||
const imageArray = data.images || [];
|
||||
|
||||
if (Array.isArray(imageArray) && imageArray.length > 0) {
|
||||
const imagesHtml = imageArray.map(image => {
|
||||
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http')
|
||||
? image
|
||||
: `/uploads/${image}`;
|
||||
|
||||
return `
|
||||
<div class="item-image-wrapper">
|
||||
<img src="${imageSrc}" alt="Buchcover / Bild" class="item-image" loading="lazy">
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const mediaHtml = `<div class="detail-gallery-container">${imagesHtml}</div>`;
|
||||
|
||||
const h2Tag = detailContent.querySelector('h2');
|
||||
if (h2Tag) {
|
||||
h2Tag.insertAdjacentHTML('afterend', mediaHtml);
|
||||
} else {
|
||||
detailContent.insertAdjacentHTML('afterbegin', mediaHtml);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading detail:', err);
|
||||
detailContent.innerHTML = '<p style="color: red;">Entschuldigung, die Details konnten nicht geladen werden.</p>';
|
||||
.catch(error => {
|
||||
console.error('Error fetching item detail:', error);
|
||||
detailContent.innerHTML = '<p style="color: red;">Fehler beim Laden der Artikeldetails.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
// Closes the modal via the 'x' button
|
||||
// Schließt das Modal über den 'x'-Button
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -293,9 +293,15 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="class_name">Klasse</label>
|
||||
<input type="text" id="class_name" name="class_name"
|
||||
<input type="text" id="class_name" name="class_name" list="class_list"
|
||||
value="{{ form_data.get('class_name', '') }}"
|
||||
placeholder="z.B. 10A">
|
||||
placeholder="z.B. 10A (Tippen oder Auswählen)">
|
||||
|
||||
<datalist id="class_list">
|
||||
{% for cls in available_classes %}
|
||||
<option value="{{ cls }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -876,12 +876,14 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Image upload -->
|
||||
<div class="form-group">
|
||||
<label for="images">Bilder:</label>
|
||||
<label>Buchcover (automatisch):</label>
|
||||
<div id="book-cover-preview-container"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="images"> Bilder hinzufügen:</label>
|
||||
<input type="file" id="images" name="images" accept=".jpg, .jpeg, .png, .gif" multiple>
|
||||
<div class="allowed-formats">Erlaubte Formate: JPG, JPEG, PNG, GIF</div>
|
||||
<!-- Add image preview area -->
|
||||
<div class="image-preview-container" id="image-preview-container"></div>
|
||||
</div>
|
||||
|
||||
@@ -1669,23 +1671,24 @@
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Function to download book cover image
|
||||
function downloadBookCover(imageUrl) {
|
||||
if (!imageUrl) {
|
||||
console.log('No image URL provided');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading indicator for image download
|
||||
const imagePreviewContainer = document.getElementById('image-preview-container');
|
||||
if (imagePreviewContainer) {
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'image-loading';
|
||||
loadingDiv.innerHTML = '<div class="loading-spinner">Buchcover wird heruntergeladen...</div>';
|
||||
imagePreviewContainer.appendChild(loadingDiv);
|
||||
|
||||
const coverPreviewContainer = document.getElementById('book-cover-preview-container');
|
||||
|
||||
if (!coverPreviewContainer) {
|
||||
console.error('Error: "book-cover-preview-container" not found in the DOM.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Download the image via backend
|
||||
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'image-loading';
|
||||
loadingDiv.innerHTML = '<div class="loading-spinner">Buchcover wird heruntergeladen...</div>';
|
||||
coverPreviewContainer.appendChild(loadingDiv);
|
||||
|
||||
fetch('/download_book_cover', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1693,78 +1696,63 @@
|
||||
},
|
||||
body: JSON.stringify({ url: imageUrl })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Remove loading indicator
|
||||
const loadingDiv = imagePreviewContainer?.querySelector('.image-loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.remove();
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
// Create a preview of the downloaded image
|
||||
const imagePreview = document.createElement('div');
|
||||
imagePreview.className = 'book-cover-preview';
|
||||
imagePreview.innerHTML = `
|
||||
<div class="preview-item">
|
||||
<img src="{{ url_for('uploaded_file', filename='') }}${data.filename}"
|
||||
alt="Buchcover" class="book-cover-thumbnail">
|
||||
<p class="book-cover-caption">Buchcover automatisch heruntergeladen</p>
|
||||
<input type="hidden" name="book_cover_image" value="${data.filename}">
|
||||
<button type="button" onclick="removeBookCover(this)"
|
||||
class="remove-book-cover-button">
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (imagePreviewContainer) {
|
||||
imagePreviewContainer.appendChild(imagePreview);
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const currentLoadingDiv = coverPreviewContainer.querySelector('.image-loading');
|
||||
if (currentLoadingDiv) {
|
||||
currentLoadingDiv.remove();
|
||||
}
|
||||
|
||||
console.log('Book cover downloaded successfully:', data.filename);
|
||||
} else {
|
||||
console.error('Failed to download book cover:', data.error);
|
||||
// Show error message to user
|
||||
if (imagePreviewContainer) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error-message';
|
||||
errorDiv.textContent = 'Fehler beim Herunterladen des Buchcovers: ' + data.error;
|
||||
errorDiv.style.fontSize = '0.8em';
|
||||
errorDiv.style.padding = '5px';
|
||||
errorDiv.style.marginTop = '5px';
|
||||
imagePreviewContainer.appendChild(errorDiv);
|
||||
|
||||
// Remove error message after 5 seconds
|
||||
setTimeout(() => errorDiv.remove(), 5000);
|
||||
|
||||
if (data.success) {
|
||||
coverPreviewContainer.innerHTML = '';
|
||||
|
||||
const imagePreview = document.createElement('div');
|
||||
imagePreview.className = 'book-cover-preview';
|
||||
imagePreview.innerHTML = `
|
||||
<div class="preview-item">
|
||||
<img src="/uploads/${data.filename}"
|
||||
alt="Buchcover" class="book-cover-thumbnail" style="max-width: 150px; border-radius: 4px;">
|
||||
<p class="book-cover-caption" style="font-size: 0.9em; color: #555;">Buchcover automatisch heruntergeladen</p>
|
||||
<input type="hidden" name="book_cover_image" value="${data.filename}">
|
||||
<button type="button" onclick="removeBookCover(this)"
|
||||
class="remove-book-cover-button btn btn-sm btn-danger">
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
coverPreviewContainer.appendChild(imagePreview);
|
||||
console.log('Book cover downloaded successfully:', data.filename);
|
||||
} else {
|
||||
console.error('Failed to download book cover:', data.error);
|
||||
showCoverError(coverPreviewContainer, 'Fehler beim Herunterladen des Buchcovers: ' + data.error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error downloading book cover:', error);
|
||||
// Remove loading indicator
|
||||
const loadingDiv = imagePreviewContainer?.querySelector('.image-loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.remove();
|
||||
}
|
||||
|
||||
// Show error message
|
||||
if (imagePreviewContainer) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error-message';
|
||||
errorDiv.textContent = 'Netzwerkfehler beim Herunterladen des Buchcovers';
|
||||
errorDiv.style.fontSize = '0.8em';
|
||||
errorDiv.style.padding = '5px';
|
||||
errorDiv.style.marginTop = '5px';
|
||||
imagePreviewContainer.appendChild(errorDiv);
|
||||
|
||||
// Remove error message after 5 seconds
|
||||
setTimeout(() => errorDiv.remove(), 5000);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error downloading book cover:', error);
|
||||
|
||||
const currentLoadingDiv = coverPreviewContainer.querySelector('.image-loading');
|
||||
if (currentLoadingDiv) {
|
||||
currentLoadingDiv.remove();
|
||||
}
|
||||
|
||||
showCoverError(coverPreviewContainer, 'Netzwerkfehler beim Herunterladen des Buchcovers');
|
||||
});
|
||||
}
|
||||
|
||||
// Function to remove downloaded book cover
|
||||
|
||||
function showCoverError(container, message) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error-message';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.fontSize = '0.8em';
|
||||
errorDiv.style.color = 'red';
|
||||
errorDiv.style.padding = '5px';
|
||||
errorDiv.style.marginTop = '5px';
|
||||
container.appendChild(errorDiv);
|
||||
|
||||
setTimeout(() => errorDiv.remove(), 5000);
|
||||
}
|
||||
|
||||
function removeBookCover(button) {
|
||||
const previewItem = button.closest('.preview-item');
|
||||
if (previewItem) {
|
||||
@@ -1772,7 +1760,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Code validation functions
|
||||
function checkCodeUnique(code, excludeId, callback) {
|
||||
if (!code || code.trim() === '') {
|
||||
callback(true);
|
||||
|
||||
Reference in New Issue
Block a user