diff --git a/app.py b/app.py index a87ef3e..e376573 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,112 @@ -from flask import Flask, render_template, jsonify import json +import os +import time +from urllib import request as urlrequest +from urllib.error import URLError, HTTPError +import requests + +from flask import Flask, render_template, jsonify, request +from flask.cli import load_dotenv app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 + +load_dotenv() +DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL") + +COOLDOWN_SECONDS = 60 * 60 +_last_feedback_by_ip = {} + +PROHIBITED_WORDS = { + 'fuck', 'shit', 'asshole', 'bitch', 'bastard', 'damn', 'crap', 'dick', 'piss', 'cunt', + 'motherfucker', 'douche', 'prick', 'whore', 'slut', 'fag', 'nigger', 'spic', 'retard', + 'twat', 'bollocks', 'wanker', 'bugger', 'arse', 'shithead', 'dumbass', 'jackass', + 'dipshit', 'dildo', 'knobhead', 'ballsack', 'buttfucker', 'cum', 'jizz', 'cocksucker', + 'fucker', 'skank', 'pussy', 'tits', 'clit', 'schlong', 'dickhead', 'goddamn', 'hell', + 'bitchass', 'fuckface', 'shitfaced', 'asshat', 'bint', 'chink', 'gook', 'jap', 'negro', + 'dyke', 'queer', 'tranny', 'tosser', 'spaz', 'mong', 'coon', 'beaner', 'cracker', + 'wetback', 'homo', 'fudgepacker', 'tard', 'cocks', 'numbnuts', 'knob', 'muppet', + 'git', 'shag', 'minge', 'nonce', 'plonker', 'pillock' +} + + +def _get_client_ip() -> str: + xff = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + return xff or (request.remote_addr or "unknown") + + +def _contains_prohibited_word(text: str) -> bool: + lower = text.lower() + return any(w in lower for w in PROHIBITED_WORDS) + + +def _post_to_discord(payload: dict) -> None: + if not DISCORD_WEBHOOK_URL: + raise RuntimeError("DISCORD_WEBHOOK_URL is not set on the server.") + url = DISCORD_WEBHOOK_URL.strip() + if "?" not in url: + url += "?wait=true" + headers = { + "User-Agent": "TRLStatsFeedback/1.0 (contact: fretux@fretux.ch)", + "Content-Type": "application/json", + "Accept": "application/json", + } + resp = requests.post(url, json=payload, headers=headers, timeout=10) + if resp.status_code not in (200, 204): + raise RuntimeError(f"Discord returned {resp.status_code}: {resp.text[:500]}") + + +@app.post("/api/feedback") +def api_feedback(): + ip = _get_client_ip() + now = time.time() + last = _last_feedback_by_ip.get(ip, 0) + + if now - last < COOLDOWN_SECONDS: + retry_after = int(COOLDOWN_SECONDS - (now - last)) + return jsonify({ + "ok": False, + "error": f"Please wait {retry_after} seconds before submitting again." + }), 429 + + data = request.get_json(silent=True) or {} + + name = (data.get("name") or "Anonymous").strip() + rating = data.get("rating") + comment = (data.get("comment") or "").strip() + + # Normalize and validate + if len(name) > 32: + name = name[:32] + + try: + rating_int = int(rating) + except (TypeError, ValueError): + return jsonify({"ok": False, "error": "Rating must be a number."}), 400 + + if rating_int < 1 or rating_int > 10: + return jsonify({"ok": False, "error": "Rating must be between 1 and 10."}), 400 + + if len(comment) < 5 or len(comment) > 500: + return jsonify({"ok": False, "error": "Comment must be between 5 and 500 characters."}), 400 + + if _contains_prohibited_word(comment): + return jsonify({"ok": False, "error": "Your comment contains inappropriate content."}), 400 + + content = f"**Rating:** {rating_int}\n**Comment:**\n{comment}" + + try: + _post_to_discord({ + "username": name, + "content": content + }) + except (HTTPError, URLError, RuntimeError) as e: + return jsonify({"ok": False, "error": f"Failed to forward feedback: {str(e)}"}), 502 + + _last_feedback_by_ip[ip] = now + return jsonify({"ok": True}), 200 + @app.route('/') def index(): diff --git a/static/filterWeapons.js b/static/filterWeapons.js index b523f7a..03d469f 100644 --- a/static/filterWeapons.js +++ b/static/filterWeapons.js @@ -116,76 +116,76 @@ function populateWeapons() { function updateWeaponStats(weapon) { const statsElement = document.getElementById('weapon-stats'); statsElement.innerHTML = ` - - - - - - - - - - - - - - - - - - - - - -
NameDamageRPMMag SizeReload TimeDPSDPMAverage DPS
${weapon.name}${(weapon.damage * weapon.damage_multiplier).toFixed(2)}${weapon.rpm}${weapon.mag_size}${weapon.reload_time.toFixed(2)} seconds
- `; + + + + + + + + + + + + + + + + + + + + + +
Name + + Damage + + Per-bullet (or per-pellet) damage after the weapon’s internal multiplier is applied. + + RPMMag SizeReload Time + + DPS + + Damage per second while continuously firing. Does not include reload downtime. + + + + DPM + + Damage per minute including reload time (sustained output). + + + + Average DPS + + Sustained DPS including reload time and magazine downtime (DPM ÷ 60). + +
${weapon.name}${weapon.mag_size}
+`; const damage = weapon.damage * weapon.damage_multiplier; const rpm = weapon.rpm; - const magSize = weapon.mag_size; const reloadTime = weapon.reload_time; - calculateDPSAndDPM(damage, rpm, magSize, reloadTime); -} - -//display the modified weapon's stats in the table -function updateModifiedWeaponStats(damage, rpm, reloadTime) { - const statsElement = document.getElementById('weapon-stats'); - statsElement.innerHTML = ` - - - - - - - - - - - - - - - - - - - - - -
NameModified DamageModified RPMMag SizeModified Reload TimeDPSDPMAverage DPS
${selectedWeapon.name}${damage.toFixed(2)}${rpm.toFixed(2)}${selectedWeapon.mag_size}${reloadTime.toFixed(2)} seconds
- `; - calculateDPSAndDPM(damage, rpm, selectedWeapon.mag_size, reloadTime); + document.getElementById('damage-value').innerText = damage.toFixed(2); + document.getElementById('rpm-value').innerText = rpm.toFixed(2); + document.getElementById('reload-value').innerText = reloadTime.toFixed(2) + ' seconds'; + calculateDPSAndDPM(damage, rpm, weapon.mag_size, reloadTime); } //calculate DPS, DPM, and Average DPS, and update the values in the table function calculateDPSAndDPM(damage, rpm, magSize, reloadTime) { - const rps = rpm / 60; // Rounds per second - let dps = damage * rps; //damage per second - const reloadTimeMin = reloadTime / 60; //variable used for dpm calculation - let dpm = (magSize * damage) / (reloadTimeMin + (magSize / rpm)); //damage per minute (including reload time) - let averageDPS = dpm / 60; //average damage per second considering reload time and mag size + const rps = rpm / 60; + const dps = damage * rps; + const reloadTimeMin = reloadTime / 60; + const dpm = (magSize * damage) / (reloadTimeMin + (magSize / rpm)); + const averageDPS = dpm / 60; document.getElementById('dps-value').innerText = dps.toFixed(2); document.getElementById('dpm-value').innerText = dpm.toFixed(2); document.getElementById('average-dps-value').innerText = averageDPS.toFixed(2); + const announcer = document.getElementById("stats-announcer"); + if (announcer) { + announcer.textContent = `Stats updated. DPS ${dps.toFixed(2)}. Average DPS ${averageDPS.toFixed(2)}. Reload time ${reloadTime.toFixed(2)} seconds.`; + } } //fetch and populate modifiers from the JSON file @@ -374,8 +374,11 @@ function applyModifiers() { if (reloadTime < 0.1) { reloadTime = 0.1; } + document.getElementById('damage-value').innerText = damage.toFixed(2); + document.getElementById('rpm-value').innerText = rpm.toFixed(2); + document.getElementById('reload-value').innerText = + reloadTime.toFixed(2) + ' seconds'; calculateDPSAndDPM(damage, rpm, selectedWeapon.mag_size, reloadTime); - updateModifiedWeaponStats(damage, rpm, reloadTime); } function applyBuff(buff, rpmModifier, reloadTimeModifier, damageModifier) { @@ -501,6 +504,7 @@ const modal = document.getElementById('weapon-form'); const form = document.getElementById('weaponForm'); const editBtn = document.getElementById('weapon-edit'); const editError = document.getElementById('edit-error'); +const submitBtn = document.getElementById('submit-btn') //Show the modal openBtn.addEventListener('click', () => { @@ -596,7 +600,7 @@ document.getElementById('copy-stats').addEventListener('click', async () => { ].join('\n'); try { await navigator.clipboard.writeText(payload); - showCopyMessage('✅ Stats copied to clipboard!', 'success'); + showCopyMessage('Stats copied to clipboard!', 'success'); } catch (err) { console.error(err); showCopyMessage('Failed to copy.', 'error'); @@ -605,7 +609,7 @@ document.getElementById('copy-stats').addEventListener('click', async () => { editBtn.addEventListener('click', () => { if (!selectedWeapon) { - editError.textContent = '⚠️ You must select a weapon first.'; + editError.textContent = 'You must select a weapon first.'; return; } editError.textContent = ''; diff --git a/static/style.css b/static/style.css index c7eed36..78e1961 100644 --- a/static/style.css +++ b/static/style.css @@ -27,7 +27,7 @@ input[type="text"] { } body { - background-image: url("static/img/trlbg1.jpg"); + background-image: url("/static/img/trlbg1.jpg"); background-repeat: no-repeat; background-attachment: fixed; background-size: cover; @@ -68,7 +68,7 @@ table { width: 100%; border-collapse: collapse; margin-bottom: 20px; - overflow: hidden; + overflow: visible; background-color: rgba(255, 255, 255, 0.8); } @@ -124,9 +124,9 @@ main { margin-top: 8vh; padding: 24px 32px 32px 32px; border-radius: 16px; - border: 1px solid rgba(0,0,0,0.08); + border: 1px solid rgba(0, 0, 0, 0.08); overflow: auto; - box-shadow: 0 10px 30px rgba(0,0,0,0.18); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18); backdrop-filter: blur(8px); } @@ -495,19 +495,13 @@ body.menu-open { } #weapon-form { - display: none; position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - padding: 20px 0; - background: rgba(0, 0, 0, 0.5); - justify-content: center; - align-items: center; - z-index: 2500; inset: 0; - overflow-y: auto; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + z-index: 3000; } #weapon-form.show { @@ -517,13 +511,12 @@ body.menu-open { #weapon-form > div { background: #fff; padding: 24px; - border-radius: 8px; + border-radius: 12px; width: 90%; max-width: 480px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); - position: relative; - max-height: calc(100vh - 40px); + max-height: 90vh; overflow-y: auto; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35); } #weapon-form h2 { @@ -791,7 +784,7 @@ body::before { content: ""; position: fixed; inset: 0; - background: linear-gradient(180deg, rgba(2,6,23,0.35), rgba(2,6,23,0.25)) , var(--bg-tint); + background: linear-gradient(180deg, rgba(2, 6, 23, 0.35), rgba(2, 6, 23, 0.25)), var(--bg-tint); pointer-events: none; z-index: 0; } @@ -816,7 +809,9 @@ a { text-decoration: none; } -a:hover { text-decoration: underline; } +a:hover { + text-decoration: underline; +} /* Inputs */ input[type="text"], input[type="number"], textarea, select, #search { @@ -838,26 +833,33 @@ button, .button1, #weapon-create, #weapon-edit, #copy-stats, #weapon-form form b padding: 10px 16px; box-shadow: 0 2px 6px rgba(2, 6, 23, 0.15); } + button:hover, .button1:hover, #weapon-create:hover, #weapon-edit:hover, #copy-stats:hover, #weapon-form form button[type="submit"]:hover { background-color: var(--primary-700); } /* Table modernization */ table { - background: rgba(255,255,255,0.85); + background: rgba(255, 255, 255, 0.85); border-radius: 12px; - overflow: hidden; + /* Avoid clipping tooltips or other positioned content inside table cells */ + overflow: visible; } thead th { position: sticky; top: 0; - background: rgba(255,255,255,0.95); + background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(6px); } -tbody tr:nth-child(odd) { background: #fafafa; } -tbody tr:hover { background: #f1f5f9; } +tbody tr:nth-child(odd) { + background: #fafafa; +} + +tbody tr:hover { + background: #f1f5f9; +} th, td { border-color: var(--border); @@ -868,24 +870,193 @@ th, td { background: rgba(15, 23, 42, 0.6); backdrop-filter: blur(6px); } + .nav-menu { background-color: rgba(255, 255, 255, 0.9); backdrop-filter: blur(8px); } + .nav-menu a { color: var(--text-900); } + .nav-menu a:hover { color: var(--primary); } /* Success/error text */ -#successMessage { color: var(--success); } -#copy-message.success { color: var(--success); } -#copy-message.error { color: var(--primary); } +#successMessage { + color: var(--success); +} + +#copy-message.success { + color: var(--success); +} + +#copy-message.error { + color: var(--primary); +} /* Responsive table spacing */ @media (max-width: 768px) { - th, td { padding: 12px; } - main { padding: 20px; } -} \ No newline at end of file + th, td { + padding: 12px; + } + + main { + padding: 20px; + } +} + +/* Screen-reader only utility */ +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.tooltip-wrap { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.tooltip-trigger { + width: 22px; + height: 22px; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.15); + background: rgba(255, 255, 255, 0.9); + cursor: pointer; + font-weight: 700; + line-height: 1; + padding: 0; + color: black; +} + +.tooltip { + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(6px); + min-width: 220px; + max-width: min(320px, calc(100vw - 24px)); + padding: 10px 12px; + border-radius: 10px; + background: rgba(15, 23, 42, 0.95); + color: #fff; + font-size: 0.9rem; + line-height: 1.35; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25); + backdrop-filter: saturate(120%) blur(2px); + opacity: 0; + pointer-events: none; + transition: opacity 0.16s ease, transform 0.16s ease; + z-index: 999999; +} + + +.tooltip::after { + content: ""; + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid rgba(15, 23, 42, 0.95); +} + +.tooltip-trigger:hover + .tooltip, +.tooltip-trigger:focus-visible + .tooltip { + opacity: 1; + transform: translateX(-50%) translateY(0); + pointer-events: auto; +} + +.tooltip-trigger:hover, +.tooltip-trigger:focus-visible { + border-color: rgba(15, 23, 42, 0.35); + box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.12); +} + +@media (prefers-reduced-motion: reduce) { + .tooltip { + transition: opacity 0.001s linear, transform 0.001s linear; + } +} + +@media (max-width: 600px) { + .tooltip { + inset: calc(100% + 8px) auto auto 50%; + transform: translate(-50%, 6px); + text-align: left; + } + + .tooltip::after { + left: 50%; + top: -6px; + transform: translateX(-50%); + border-right: 6px solid transparent; + border-left: 6px solid transparent; + border-top: none; + border-bottom: 6px solid rgba(15, 23, 42, 0.95); + } + + .tooltip-trigger:hover + .tooltip, + .tooltip-trigger:focus + .tooltip { + transform: translate(-50%, 0); + } +} + +@media screen and (max-width: 600px) { + .weapon-stats-table.horizontal-table { + border-collapse: separate; + border-spacing: 0; + } + + .weapon-stats-table.horizontal-table tr:first-child { + display: none; + } + + .weapon-stats-table.horizontal-table tr { + display: block; + margin-bottom: 14px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + overflow: visible; + background: rgba(255, 255, 255, 0.9); + } + + .weapon-stats-table.horizontal-table td { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 12px 14px; + border: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + text-align: right; + } + + .weapon-stats-table.horizontal-table td:last-child { + border-bottom: none; + } + + .weapon-stats-table.horizontal-table td::before { + content: attr(data-label); + font-weight: 700; + text-align: left; + color: rgba(15, 23, 42, 0.9); + flex: 1; + } +} diff --git a/static/suggestion.js b/static/suggestion.js index cf3eb18..3439fd9 100644 --- a/static/suggestion.js +++ b/static/suggestion.js @@ -1,12 +1,5 @@ -const cooldownDuration = 10 * 360000; -const prohibitedWords = ['fuck', 'shit', 'asshole', 'bitch', 'bastard', 'damn', 'crap', 'dick', 'piss', 'cunt', 'motherfucker', 'douche', -'prick', 'whore', 'slut', 'fag', 'nigger', 'spic', 'retard', 'twat', 'bollocks', 'wanker', 'bugger', 'arse', -'shithead', 'dumbass', 'jackass', 'dipshit', 'dildo', 'knobhead', 'ballsack', 'buttfucker', 'cum', 'jizz', -'cocksucker', 'fucker', 'skank', 'pussy', 'tits', 'clit', 'schlong', 'dickhead', 'goddamn', 'hell', 'bitchass', -'fuckface', 'shitfaced', 'asshat', 'bint', 'chink', 'gook', 'jap', 'negro', 'dyke', 'queer', 'tranny', 'tosser', -'spaz', 'mong', 'coon', 'beaner', 'cracker', 'wetback', 'homo', 'fudgepacker', 'tard', 'cocks', 'bollocks', -'numbnuts', 'knob', 'muppet', 'git', 'shag', 'minge', 'git', 'bugger', 'tosser', 'nonce', 'plonker', 'pillock'] -; +const cooldownDuration = 60 * 60 * 1000; +const prohibitedWords = [] function getLastSubmissionTime() { const storedTime = localStorage.getItem('lastSubmissionTime'); @@ -17,16 +10,21 @@ function setLastSubmissionTime(time) { localStorage.setItem('lastSubmissionTime', time.toString()); } -function sendMessage() { +async function sendMessage() { const currentTime = Date.now(); const lastSubmissionTime = getLastSubmissionTime(); if (currentTime - lastSubmissionTime < cooldownDuration) { - alert(`Please wait for ${cooldownDuration / 1000} seconds between submissions.`); + alert(`Please wait ${Math.ceil((cooldownDuration - (currentTime - lastSubmissionTime)) / 1000)} seconds between submissions.`); return; } const pseudoname = document.getElementById("name").value || "Anonymous"; const rating = parseInt(document.getElementById("rate").value, 10); const comment = document.getElementById("text").value.trim(); + const successMessage = document.getElementById("successMessage"); + const errorMessage = document.getElementById("errorMessage"); + successMessage.style.display = "none"; + errorMessage.style.display = "none"; + if (isNaN(rating) || rating < 1 || rating > 10) { alert("Rating must be a number between 1 and 10."); return; @@ -41,24 +39,41 @@ function sendMessage() { return; } } - const request = new XMLHttpRequest(); - request.open("POST", "https://discord.com/api/webhooks/1284936894890901649/Zi-2ZV12L1v1OSBoM-DluE1V1LECXilrNNRZYJcxelnwO7rGgXNH47dxvfFEOK7Fe9_J"); - request.setRequestHeader('Content-type', 'application/json'); - const content = `**Rating:** ${rating}\n**Comment:**\n${comment}`; - console.log('Sending message with content:', content); - const params = { - username: pseudoname, - content: content - }; - request.send(JSON.stringify(params)); - setLastSubmissionTime(currentTime); - document.getElementById("name").value = ""; - document.getElementById("rate").value = ""; - document.getElementById("text").value = ""; - const successMessage = document.getElementById("successMessage"); - successMessage.textContent = "Successfully sent the message!"; - successMessage.style.display = "block"; - setTimeout(() => { - successMessage.style.display = "none"; - }, 3000); -} + + try { + const res = await fetch("/api/feedback", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + name: pseudoname, + rating: rating, + comment: comment + }) + }); + + const json = await res.json().catch(() => ({})); + + if (!res.ok || !json.ok) { + const msg = (json && json.error) ? json.error : "Failed to send feedback."; + errorMessage.textContent = msg; + errorMessage.style.display = "block"; + return; + } + + setLastSubmissionTime(currentTime); + document.getElementById("name").value = ""; + document.getElementById("rate").value = ""; + document.getElementById("text").value = ""; + + successMessage.textContent = "Successfully sent the message!"; + successMessage.style.display = "block"; + setTimeout(() => { + successMessage.style.display = "none"; + }, 3000); + + } catch (err) { + console.error(err); + errorMessage.textContent = "Network error: could not send feedback."; + errorMessage.style.display = "block"; + } +} \ No newline at end of file diff --git a/templates/about_us.html b/templates/about_us.html index d715e49..d6bce19 100644 --- a/templates/about_us.html +++ b/templates/about_us.html @@ -11,12 +11,12 @@