Performance-Optimierung: 9.9 MB → 124 KB (-98.7%)
- /media/<filename> Endpunkt mit Cache-Control (max-age=86400, ETag) - Thumbnail-Generator für Bilder (300px via PIL, on-the-fly) - Frontend-Karten: URLs statt Base64 + Thumbnails + loading=lazy - Admin-Detailseite + öffentliche Detailseite ebenfalls auf URLs - Video-Thumbnails als separate Dateien via /media/ statt Base64
This commit is contained in:
@@ -1074,24 +1074,15 @@ def submission_detail(sub_id: int):
|
|||||||
_, ext = os.path.splitext(path)
|
_, ext = os.path.splitext(path)
|
||||||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
basename = os.path.basename(path)
|
||||||
b64 = base64.b64encode(f.read()).decode()
|
src = f"/media/{basename}"
|
||||||
src = f"data:{mime};base64,{b64}"
|
|
||||||
media_html += f'<div class="media-item" style="margin:0.5rem 0;background:var(--surface);border-radius:var(--radius-sm);overflow:hidden;">'
|
media_html += f'<div class="media-item" style="margin:0.5rem 0;background:var(--surface);border-radius:var(--radius-sm);overflow:hidden;">'
|
||||||
# Media element
|
# Media element
|
||||||
if mime.startswith("image/"):
|
if mime.startswith("image/"):
|
||||||
media_html += f'<img src="{src}" style="width:100%;max-height:400px;object-fit:contain;display:block;background:#000;">'
|
media_html += f'<img src="{src}" style="width:100%;max-height:400px;object-fit:contain;display:block;background:#000;" loading="lazy">'
|
||||||
elif mime.startswith("video/"):
|
elif mime.startswith("video/"):
|
||||||
# Generate thumbnail for poster
|
|
||||||
thumb = generate_video_thumbnail(path)
|
thumb = generate_video_thumbnail(path)
|
||||||
poster_attr = ""
|
poster_attr = f' poster="/media/{os.path.basename(thumb)}"' if thumb else ""
|
||||||
if thumb:
|
|
||||||
try:
|
|
||||||
with open(thumb, "rb") as tf:
|
|
||||||
thumb_b64 = base64.b64encode(tf.read()).decode()
|
|
||||||
poster_attr = f' poster="data:image/jpeg;base64,{thumb_b64}"'
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
media_html += f'<video controls preload="metadata"{poster_attr} style="width:100%;max-height:400px;background:#000;"><source src="{src}" type="{mime}"></video>'
|
media_html += f'<video controls preload="metadata"{poster_attr} style="width:100%;max-height:400px;background:#000;"><source src="{src}" type="{mime}"></video>'
|
||||||
elif mime.startswith("audio/"):
|
elif mime.startswith("audio/"):
|
||||||
media_html += f'<audio controls style="width:100%;padding:0.8rem;"><source src="{src}" type="{mime}"></audio>'
|
media_html += f'<audio controls style="width:100%;padding:0.8rem;"><source src="{src}" type="{mime}"></audio>'
|
||||||
@@ -1711,17 +1702,21 @@ def public_frontend():
|
|||||||
continue
|
continue
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
continue
|
continue
|
||||||
|
basename = os.path.basename(path)
|
||||||
_, ext = os.path.splitext(path)
|
_, ext = os.path.splitext(path)
|
||||||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
|
||||||
b64 = base64.b64encode(f.read()).decode()
|
|
||||||
src = "data:" + mime + ";base64," + b64
|
|
||||||
if mime.startswith("image/"):
|
if mime.startswith("image/"):
|
||||||
|
# Use thumbnail for frontend cards
|
||||||
|
thumb = generate_image_thumbnail(path)
|
||||||
|
if thumb:
|
||||||
|
img_src = f"/media/{os.path.basename(thumb)}"
|
||||||
|
else:
|
||||||
|
img_src = f"/media/{basename}"
|
||||||
img_tag = (
|
img_tag = (
|
||||||
'<a href="/beitrag/' + str(r["id"]) + '" class="media-preview-link">'
|
'<a href="/beitrag/' + str(r["id"]) + '" class="media-preview-link">'
|
||||||
'<div class="media-crop" style="width:100%;height:160px;overflow:hidden;border-radius:8px;">'
|
'<div class="media-crop" style="width:100%;height:160px;overflow:hidden;border-radius:8px;">'
|
||||||
'<img src="' + src + '" alt="Beitragsbild" loading="lazy" style="width:100%;height:100%;object-fit:cover;">'
|
'<img src="' + img_src + '" alt="Beitragsbild" loading="lazy" style="width:100%;height:100%;object-fit:cover;">'
|
||||||
'</div></a>'
|
'</div></a>'
|
||||||
)
|
)
|
||||||
if i == 0 and not media_first:
|
if i == 0 and not media_first:
|
||||||
@@ -1730,16 +1725,11 @@ def public_frontend():
|
|||||||
media_html += img_tag
|
media_html += img_tag
|
||||||
elif mime.startswith("video/"):
|
elif mime.startswith("video/"):
|
||||||
thumb = generate_video_thumbnail(path)
|
thumb = generate_video_thumbnail(path)
|
||||||
if thumb:
|
thumb_src = f"/media/{os.path.basename(thumb)}" if thumb else ""
|
||||||
with open(thumb, "rb") as f:
|
|
||||||
thumb_b64 = base64.b64encode(f.read()).decode()
|
|
||||||
thumb_src = "data:image/jpeg;base64," + thumb_b64
|
|
||||||
else:
|
|
||||||
thumb_src = ""
|
|
||||||
vid_tag = (
|
vid_tag = (
|
||||||
'<a href="/beitrag/' + str(r["id"]) + '" class="video-thumb-link">'
|
'<a href="/beitrag/' + str(r["id"]) + '" class="video-thumb-link">'
|
||||||
'<div class="video-thumb" style="position:relative;background:#000;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;overflow:hidden;">'
|
'<div class="video-thumb" style="position:relative;background:#000;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;overflow:hidden;">'
|
||||||
+ (f'<img src="{thumb_src}" alt="Video-Vorschau" style="width:100%;height:100%;object-fit:cover;opacity:0.7;">' if thumb_src else '<div style="color:#555;font-size:3rem;">🎬</div>')
|
+ (f'<img src="{thumb_src}" alt="Video-Vorschau" loading="lazy" style="width:100%;height:100%;object-fit:cover;opacity:0.7;">' if thumb_src else '<div style="color:#555;font-size:3rem;">🎬</div>')
|
||||||
+ '<div class="play-btn" style="position:absolute;width:60px;height:60px;background:rgba(255,255,255,0.9);border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(0,0,0,0.4);">'
|
+ '<div class="play-btn" style="position:absolute;width:60px;height:60px;background:rgba(255,255,255,0.9);border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(0,0,0,0.4);">'
|
||||||
+ '<svg width="24" height="28" viewBox="0 0 24 28"><path d="M0 0v28l24-14z" fill="#20228a"/></svg>'
|
+ '<svg width="24" height="28" viewBox="0 0 24 28"><path d="M0 0v28l24-14z" fill="#20228a"/></svg>'
|
||||||
+ '</div></div></a>'
|
+ '</div></div></a>'
|
||||||
@@ -2059,7 +2049,7 @@ def public_frontend():
|
|||||||
<div class="chat-body" id="chatBody">
|
<div class="chat-body" id="chatBody">
|
||||||
<div style="display:flex;align-items:flex-end;gap:0.3rem;align-self:flex-start;">
|
<div style="display:flex;align-items:flex-end;gap:0.3rem;align-self:flex-start;">
|
||||||
<div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div>
|
<div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div>
|
||||||
<button onclick="(function(t){var u=new SpeechSynthesisUtterance(t);u.lang='de-DE';u.rate=0.95;var v=speechSynthesis.getVoices();var th=v.find(function(x){return x.name==='Thorsten'});if(th)u.voice=th;else{var d=v.find(function(x){return x.lang.startsWith('de')});if(d)u.voice=d}speechSynthesis.cancel();speechSynthesis.speak(u)})('Hey! Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!')" title="Vorlesen" style="background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;" onmouseenter="this.style.background='rgba(32,34,138,0.18)';this.style.borderColor='rgba(32,34,138,0.4)'" onmouseleave="this.style.background='rgba(32,34,138,0.08)';this.style.borderColor='rgba(32,34,138,0.2)'">🔊</button>
|
<button id="greetingSpeakBtn" title="Vorlesen" style="background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;" onmouseenter="this.style.background='rgba(32,34,138,0.18)';this.style.borderColor='rgba(32,34,138,0.4)'" onmouseleave="this.style.background='rgba(32,34,138,0.08)';this.style.borderColor='rgba(32,34,138,0.2)'">🔊</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-footer">
|
<div class="chat-footer">
|
||||||
@@ -2261,6 +2251,14 @@ def public_frontend():
|
|||||||
bubble.addEventListener('click', toggle);
|
bubble.addEventListener('click', toggle);
|
||||||
closeBtn.addEventListener('click', () => { open = false; overlay.classList.remove('open'); });
|
closeBtn.addEventListener('click', () => { open = false; overlay.classList.remove('open'); });
|
||||||
|
|
||||||
|
// Wire up greeting speak button
|
||||||
|
const greetBtn = document.getElementById('greetingSpeakBtn');
|
||||||
|
if (greetBtn) {
|
||||||
|
greetBtn.addEventListener('click', () => {
|
||||||
|
speak('Hey! Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addMsg(role, text) {
|
function addMsg(role, text) {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.style.display = 'flex';
|
wrapper.style.display = 'flex';
|
||||||
@@ -2374,6 +2372,44 @@ def public_frontend():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image_thumbnail(image_path: str, width: int = 300) -> str | None:
|
||||||
|
"""Generate a thumbnail for an image. Returns path to .thumb.jpg or None."""
|
||||||
|
thumb_path = image_path + ".thumb.jpg"
|
||||||
|
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 100:
|
||||||
|
return thumb_path
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(image_path)
|
||||||
|
img.thumbnail((width, width * 3), Image.LANCZOS)
|
||||||
|
if img.mode in ('RGBA', 'P'):
|
||||||
|
img = img.convert('RGB')
|
||||||
|
img.save(thumb_path, 'JPEG', quality=75)
|
||||||
|
return thumb_path
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/media/<path:filename>')
|
||||||
|
def serve_media(filename):
|
||||||
|
"""Serve media files with aggressive caching headers."""
|
||||||
|
safe = os.path.normpath(filename)
|
||||||
|
if '..' in safe or safe.startswith('/'):
|
||||||
|
return '', 404
|
||||||
|
filepath = MEDIA_DIR / safe
|
||||||
|
if not filepath.is_file():
|
||||||
|
# Try with .thumb.jpg suffix
|
||||||
|
filepath_thumb = MEDIA_DIR / (safe + '.thumb.jpg')
|
||||||
|
if filepath_thumb.is_file():
|
||||||
|
filepath = filepath_thumb
|
||||||
|
else:
|
||||||
|
return '', 404
|
||||||
|
from flask import send_file
|
||||||
|
response = send_file(str(filepath))
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=86400, immutable'
|
||||||
|
response.headers['ETag'] = f'"{filepath.stat().st_mtime}-{filepath.stat().st_size}"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/query')
|
@app.route('/api/query')
|
||||||
def api_query():
|
def api_query():
|
||||||
'''Instant lookup: find submissions by hall, keyword, or sender.'''
|
'''Instant lookup: find submissions by hall, keyword, or sender.'''
|
||||||
@@ -2682,14 +2718,15 @@ def public_detail(sub_id: int):
|
|||||||
_, ext = os.path.splitext(path)
|
_, ext = os.path.splitext(path)
|
||||||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
basename = os.path.basename(path)
|
||||||
b64 = base64.b64encode(f.read()).decode()
|
src = f"/media/{basename}"
|
||||||
src = "data:" + mime + ";base64," + b64
|
|
||||||
if mime.startswith("image/"):
|
if mime.startswith("image/"):
|
||||||
media_html += f'<img src="{src}" alt="Bild" style="width:100%;height:auto;border-radius:12px;margin:0.5rem 0;">'
|
media_html += f'<img src="{src}" alt="Bild" style="width:100%;height:auto;border-radius:12px;margin:0.5rem 0;">'
|
||||||
elif mime.startswith("video/"):
|
elif mime.startswith("video/"):
|
||||||
|
thumb = generate_video_thumbnail(path)
|
||||||
|
poster = f' poster="/media/{os.path.basename(thumb)}"' if thumb else ""
|
||||||
media_html += (
|
media_html += (
|
||||||
f'<video controls playsinline style="width:100%;max-height:80vh;border-radius:12px;margin:0.5rem 0;background:#000;">'
|
f'<video controls playsinline{poster} style="width:100%;max-height:80vh;border-radius:12px;margin:0.5rem 0;background:#000;">'
|
||||||
f'<source src="{src}" type="{mime}"></video>'
|
f'<source src="{src}" type="{mime}"></video>'
|
||||||
)
|
)
|
||||||
elif mime.startswith("audio/"):
|
elif mime.startswith("audio/"):
|
||||||
|
|||||||
Reference in New Issue
Block a user