feat: Kommentar-System mit WhatsApp- & E-Mail-Benachrichtigung
- Neue comments-Tabelle (submission_id, author_name, content) - POST /api/comment/<id> — Kommentar speichern + Benachrichtigung senden - WhatsApp: Sender wird per WhatsApp informiert mit Link - E-Mail: Web-Einreicher mit angegebener E-Mail werden benachrichtigt - _send_email() über sendmail für Mail-Versand - Kommentar-Formular + Anzeige auf Detailseite (/beitrag/<id>) - E-Mail-Feld im Web-Submit-Formular (+ Overlay) - email-Spalte in submissions-Tabelle
This commit is contained in:
@@ -267,6 +267,26 @@ def send_whatsapp_audio(to: str, media_id: str) -> dict:
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_email(to: str, subject: str, body: str) -> None:
|
||||||
|
"""Send an email notification using sendmail."""
|
||||||
|
import subprocess
|
||||||
|
from email.message import EmailMessage
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = "noreply@brettspiel-news.de"
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(body)
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["/usr/sbin/sendmail", "-t", "-oi"],
|
||||||
|
input=msg.as_bytes(),
|
||||||
|
timeout=10,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # silently ignore email failures
|
||||||
|
|
||||||
|
|
||||||
def download_media(media_id: str) -> tuple[str | None, str | None]:
|
def download_media(media_id: str) -> tuple[str | None, str | None]:
|
||||||
"""Download media from WhatsApp. Returns (local_path, mime_type) or (None, None)."""
|
"""Download media from WhatsApp. Returns (local_path, mime_type) or (None, None)."""
|
||||||
url = f"https://graph.facebook.com/v25.0/{media_id}"
|
url = f"https://graph.facebook.com/v25.0/{media_id}"
|
||||||
@@ -2199,6 +2219,8 @@ def public_frontend():
|
|||||||
</div>
|
</div>
|
||||||
<label>Dein Name (optional)</label>
|
<label>Dein Name (optional)</label>
|
||||||
<input type="text" id="subName" placeholder="Vorname oder Spitzname" maxlength="100">
|
<input type="text" id="subName" placeholder="Vorname oder Spitzname" maxlength="100">
|
||||||
|
<label>E-Mail für Benachrichtigungen (optional)</label>
|
||||||
|
<input type="email" id="subEmail" placeholder="deine@email.de" maxlength="200">
|
||||||
<label>Nachricht (optional, reicht auch ohne Text)</label>
|
<label>Nachricht (optional, reicht auch ohne Text)</label>
|
||||||
<textarea id="subText" placeholder="Was möchtest du mitteilen? ..." maxlength="20000"></textarea>
|
<textarea id="subText" placeholder="Was möchtest du mitteilen? ..." maxlength="20000"></textarea>
|
||||||
<label>📎 Medien aufnehmen oder auswählen</label>
|
<label>📎 Medien aufnehmen oder auswählen</label>
|
||||||
@@ -2262,6 +2284,7 @@ def public_frontend():
|
|||||||
|
|
||||||
sSend.addEventListener('click', async () => {
|
sSend.addEventListener('click', async () => {
|
||||||
const name = document.getElementById('subName').value.trim();
|
const name = document.getElementById('subName').value.trim();
|
||||||
|
const email = document.getElementById('subEmail').value.trim();
|
||||||
const text = document.getElementById('subText').value.trim();
|
const text = document.getElementById('subText').value.trim();
|
||||||
const fileInput = document.getElementById('subPhoto');
|
const fileInput = document.getElementById('subPhoto');
|
||||||
const file = fileInput.files[0] || document.getElementById('subVideo').files[0] || document.getElementById('subAudio').files[0] || document.getElementById('subGallery').files[0];
|
const file = fileInput.files[0] || document.getElementById('subVideo').files[0] || document.getElementById('subAudio').files[0] || document.getElementById('subGallery').files[0];
|
||||||
@@ -2280,6 +2303,7 @@ def public_frontend():
|
|||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (name) formData.append('name', name);
|
if (name) formData.append('name', name);
|
||||||
|
if (email) formData.append('email', email);
|
||||||
if (text) formData.append('text', text);
|
if (text) formData.append('text', text);
|
||||||
if (file) formData.append('media', file);
|
if (file) formData.append('media', file);
|
||||||
if (halle) formData.append('halle', halle);
|
if (halle) formData.append('halle', halle);
|
||||||
@@ -2301,6 +2325,7 @@ def public_frontend():
|
|||||||
+ '</div>';
|
+ '</div>';
|
||||||
sStatus.style.color = '#20228a';
|
sStatus.style.color = '#20228a';
|
||||||
document.getElementById('subName').value = '';
|
document.getElementById('subName').value = '';
|
||||||
|
document.getElementById('subEmail').value = '';
|
||||||
document.getElementById('subText').value = '';
|
document.getElementById('subText').value = '';
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
document.getElementById('subVideo').value = '';
|
document.getElementById('subVideo').value = '';
|
||||||
@@ -3007,6 +3032,7 @@ def api_submit():
|
|||||||
text = (request.form.get('text') or '').strip()[:5000]
|
text = (request.form.get('text') or '').strip()[:5000]
|
||||||
halle = (request.form.get('halle') or '').strip()[:20]
|
halle = (request.form.get('halle') or '').strip()[:20]
|
||||||
spiel_titel = (request.form.get('spiel_titel') or '').strip()[:200]
|
spiel_titel = (request.form.get('spiel_titel') or '').strip()[:200]
|
||||||
|
email = (request.form.get('email') or '').strip()[:200]
|
||||||
|
|
||||||
# Require at least text or a file
|
# Require at least text or a file
|
||||||
file = request.files.get('media')
|
file = request.files.get('media')
|
||||||
@@ -3087,9 +3113,9 @@ def api_submit():
|
|||||||
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"""INSERT INTO submissions
|
"""INSERT INTO submissions
|
||||||
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel)
|
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, email)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?)""",
|
||||||
('web', sender_id, sender_name, msg_type, text or None, media_path, halle_valid, category, spiel_titel or None),
|
('web', sender_id, sender_name, msg_type, text or None, media_path, halle_valid, category, spiel_titel or None, email or None),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
sub_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
sub_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
@@ -3186,6 +3212,25 @@ def public_detail(sub_id: int):
|
|||||||
|
|
||||||
content = (row["summary"] or row["content"] or "").strip()
|
content = (row["summary"] or row["content"] or "").strip()
|
||||||
|
|
||||||
|
# Comments
|
||||||
|
comment_rows = db.execute(
|
||||||
|
"SELECT author_name, content, created_at FROM comments WHERE submission_id = ? ORDER BY created_at ASC",
|
||||||
|
(sub_id,),
|
||||||
|
).fetchall()
|
||||||
|
comments_html = ""
|
||||||
|
for c in comment_rows:
|
||||||
|
c_author = (c["author_name"] or "Anonym").strip()
|
||||||
|
c_date = c["created_at"] or ""
|
||||||
|
try:
|
||||||
|
cd = datetime.strptime(c_date, "%Y-%m-%d %H:%M:%S")
|
||||||
|
c_date = cd.strftime("%d.%m.%y %H:%M")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
comments_html += f"""<div class="comment-item">
|
||||||
|
<div class="comment-header"><strong class="comment-author">{c_author}</strong> <span class="comment-date">{c_date}</span></div>
|
||||||
|
<p class="comment-text">{c["content"]}</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
@@ -3213,6 +3258,22 @@ def public_detail(sub_id: int):
|
|||||||
.detail-author {{ font-weight:700; color:#20228a; font-size:1rem; }}
|
.detail-author {{ font-weight:700; color:#20228a; font-size:1rem; }}
|
||||||
.detail-date {{ color:#999; font-size:0.8rem; margin-left:auto; }}
|
.detail-date {{ color:#999; font-size:0.8rem; margin-left:auto; }}
|
||||||
.detail-halle {{ display:inline-block; background:#20228a; color:#fff; padding:0.35rem 0.8rem; border-radius:6px; font-size:0.85rem; font-weight:700; margin-bottom:1rem; }}
|
.detail-halle {{ display:inline-block; background:#20228a; color:#fff; padding:0.35rem 0.8rem; border-radius:6px; font-size:0.85rem; font-weight:700; margin-bottom:1rem; }}
|
||||||
|
.comments-section {{ max-width:720px; margin:2rem auto 0; padding:0 1.5rem; }}
|
||||||
|
.comments-heading {{ font-size:1rem; font-weight:700; color:#1a1a2e; margin-bottom:1rem; }}
|
||||||
|
.comment-item {{ background:#f8f6f0; border-radius:10px; padding:0.8rem 1rem; margin-bottom:0.6rem; }}
|
||||||
|
.comment-header {{ display:flex; align-items:baseline; gap:0.5rem; margin-bottom:0.3rem; }}
|
||||||
|
.comment-author {{ font-size:0.85rem; color:#20228a; }}
|
||||||
|
.comment-date {{ font-size:0.7rem; color:#aaa; }}
|
||||||
|
.comment-text {{ font-size:0.9rem; color:#333; line-height:1.5; white-space:pre-wrap; word-break:break-word; }}
|
||||||
|
.comment-form {{ margin-top:1.5rem; display:flex; flex-direction:column; gap:0.6rem; }}
|
||||||
|
.comment-input {{ padding:0.6rem 0.9rem; border:1.5px solid #ddd; border-radius:10px; font-size:0.85rem; font-family:inherit; }}
|
||||||
|
.comment-input:focus {{ outline:none; border-color:#20228a; }}
|
||||||
|
.comment-textarea {{ padding:0.6rem 0.9rem; border:1.5px solid #ddd; border-radius:10px; font-size:0.85rem; font-family:inherit; min-height:80px; resize:vertical; }}
|
||||||
|
.comment-textarea:focus {{ outline:none; border-color:#20228a; }}
|
||||||
|
.comment-submit-btn {{ align-self:flex-end; background:#20228a; color:#fff; border:none; border-radius:10px; padding:0.55rem 1.5rem; font-size:0.85rem; font-weight:600; cursor:pointer; font-family:inherit; transition:background 0.15s; }}
|
||||||
|
.comment-submit-btn:hover {{ background:#161e6e; }}
|
||||||
|
.comment-submit-btn:active {{ transform:scale(0.97); }}
|
||||||
|
.comment-submit-btn:disabled {{ opacity:0.6; pointer-events:none; }}
|
||||||
.detail-content {{ font-size:1.05rem; line-height:1.7; color:#333; white-space:pre-wrap; word-break:break-word; }}
|
.detail-content {{ font-size:1.05rem; line-height:1.7; color:#333; white-space:pre-wrap; word-break:break-word; }}
|
||||||
.detail-media {{ margin-top:1.5rem; }}
|
.detail-media {{ margin-top:1.5rem; }}
|
||||||
.site-footer {{ text-align:center; padding:2rem; border-top:1px solid #eee; margin-top:3rem; }}
|
.site-footer {{ text-align:center; padding:2rem; border-top:1px solid #eee; margin-top:3rem; }}
|
||||||
@@ -3250,6 +3311,16 @@ def public_detail(sub_id: int):
|
|||||||
<button id="detail-like-btn" data-id="{sub_id}" class="detail-like-btn"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#20228a" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:2px;"><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/><path d="M7 11h11.5a2.5 2.5 0 0 1 2.5 2.5v.5a2.5 2.5 0 0 1-2.5 2.5h-2.5"/><path d="M16 16.5V19a3 3 0 0 1-6 1.5L7 11"/></svg> {row['likes'] or 0}</button>
|
<button id="detail-like-btn" data-id="{sub_id}" class="detail-like-btn"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#20228a" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:2px;"><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/><path d="M7 11h11.5a2.5 2.5 0 0 1 2.5 2.5v.5a2.5 2.5 0 0 1-2.5 2.5h-2.5"/><path d="M16 16.5V19a3 3 0 0 1-6 1.5L7 11"/></svg> {row['likes'] or 0}</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
<section class="comments-section">
|
||||||
|
<h3 class="comments-heading">💬 Kommentare ({len(comment_rows)})</h3>
|
||||||
|
{comments_html}
|
||||||
|
<form id="commentForm" class="comment-form" onsubmit="return submitComment(event, {sub_id})">
|
||||||
|
<input type="text" name="author" placeholder="Dein Name (optional)" maxlength="80" class="comment-input">
|
||||||
|
<textarea name="content" placeholder="Schreib einen Kommentar..." maxlength="2000" required class="comment-textarea"></textarea>
|
||||||
|
<button type="submit" class="comment-submit-btn">Kommentar senden</button>
|
||||||
|
</form>
|
||||||
|
<div id="commentStatus" style="display:none;margin-top:0.5rem;padding:0.5rem;border-radius:8px;font-size:0.85rem;"></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>Powered by <a href="https://brettspiel-news.de">brettspiel-news.de</a></p>
|
<p>Powered by <a href="https://brettspiel-news.de">brettspiel-news.de</a></p>
|
||||||
@@ -3291,6 +3362,51 @@ def public_detail(sub_id: int):
|
|||||||
.catch(function() {{ btn.disabled = false; }});
|
.catch(function() {{ btn.disabled = false; }});
|
||||||
}});
|
}});
|
||||||
}})();
|
}})();
|
||||||
|
function submitComment(event, subId) {{
|
||||||
|
event.preventDefault();
|
||||||
|
var form = document.getElementById('commentForm');
|
||||||
|
var status = document.getElementById('commentStatus');
|
||||||
|
var btn = form.querySelector('.comment-submit-btn');
|
||||||
|
var author = form.author.value.trim();
|
||||||
|
var content = form.content.value.trim();
|
||||||
|
if (!content) return false;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Wird gesendet...';
|
||||||
|
status.style.display = 'none';
|
||||||
|
fetch('/api/comment/' + subId, {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{'Content-Type': 'application/json'}},
|
||||||
|
body: JSON.stringify({{author: author, content: content}})
|
||||||
|
}})
|
||||||
|
.then(function(r) {{ return r.json(); }})
|
||||||
|
.then(function(data) {{
|
||||||
|
if (data.ok) {{
|
||||||
|
status.style.display = 'block';
|
||||||
|
status.style.background = '#e8f5e9';
|
||||||
|
status.style.color = '#2e7d32';
|
||||||
|
status.textContent = '✅ Kommentar gesendet!';
|
||||||
|
form.reset();
|
||||||
|
// Reload after short delay to show the new comment
|
||||||
|
setTimeout(function() {{ location.reload(); }}, 1200);
|
||||||
|
}} else {{
|
||||||
|
status.style.display = 'block';
|
||||||
|
status.style.background = '#fce4ec';
|
||||||
|
status.style.color = '#c62828';
|
||||||
|
status.textContent = '❌ ' + (data.error || 'Fehler');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Kommentar senden';
|
||||||
|
}}
|
||||||
|
}})
|
||||||
|
.catch(function() {{
|
||||||
|
status.style.display = 'block';
|
||||||
|
status.style.background = '#fce4ec';
|
||||||
|
status.style.color = '#c62828';
|
||||||
|
status.textContent = '❌ Netzwerkfehler';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Kommentar senden';
|
||||||
|
}});
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -3320,6 +3436,64 @@ def api_like(sub_id: int):
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/comment/<int:sub_id>", methods=["POST"])
|
||||||
|
def api_comment(sub_id: int):
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT id, channel, sender_id, sender_name, email FROM submissions WHERE id = ? AND status='published' AND deleted_at IS NULL",
|
||||||
|
(sub_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "Beitrag nicht gefunden"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
author = (data.get("author") or "").strip()[:80]
|
||||||
|
content = (data.get("content") or "").strip()[:2000]
|
||||||
|
if not content:
|
||||||
|
return jsonify({"error": "Kommentar darf nicht leer sein"}), 400
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO comments (submission_id, author_name, content) VALUES (?, ?, ?)",
|
||||||
|
(sub_id, author or None, content),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Build notification link
|
||||||
|
base = request.host_url.rstrip("/")
|
||||||
|
link = f"{base}/beitrag/{sub_id}"
|
||||||
|
|
||||||
|
# WhatsApp notification (only for WhatsApp submissions)
|
||||||
|
if row["channel"] == "whatsapp" and row["sender_id"]:
|
||||||
|
try:
|
||||||
|
msg = (
|
||||||
|
f"💬 *Neuer Kommentar zu deinem Beitrag!*\n\n"
|
||||||
|
f"{'*' + (author or 'Jemand') + '* schreibt' if author else 'Jemand hat kommentiert'}:\n"
|
||||||
|
f"_{content[:200]}{'...' if len(content) > 200 else ''}_\n\n"
|
||||||
|
f"👉 {link}"
|
||||||
|
)
|
||||||
|
send_whatsapp_message(row["sender_id"], msg)
|
||||||
|
except Exception:
|
||||||
|
pass # silently ignore notification failures
|
||||||
|
|
||||||
|
# Email notification (only for web submissions with email)
|
||||||
|
if row["channel"] == "web" and row["email"]:
|
||||||
|
try:
|
||||||
|
subject = f"💬 Neuer Kommentar zu deinem BSN-Beitrag #{sub_id}"
|
||||||
|
body = (
|
||||||
|
f"Hallo,\n\n"
|
||||||
|
f"{author or 'Jemand'} hat deinen Beitrag kommentiert:\n\n"
|
||||||
|
f"\"{content[:300]}{'...' if len(content) > 300 else ''}\"\n\n"
|
||||||
|
f"👉 {link}\n\n"
|
||||||
|
f"Viele Grüße,\n"
|
||||||
|
f"Boardgame Social / brettspiel-news.de\n"
|
||||||
|
)
|
||||||
|
_send_email(row["email"], subject, body)
|
||||||
|
except Exception:
|
||||||
|
pass # silently ignore notification failures
|
||||||
|
|
||||||
|
return jsonify({"ok": True}), 201
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import threading
|
import threading
|
||||||
def keepalive():
|
def keepalive():
|
||||||
|
|||||||
Reference in New Issue
Block a user