commit 8fee3bf020095eb7d85849c4d43eee8c67e17e42 Author: Sophia Atkinson Date: Thu Nov 20 01:00:38 2025 -0800 Initial commit :) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7f9a00b --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +VERSION="1.0.6-beta" + +SITE_NAME="yougirl city" +THEME_COLOR="#F5A9B8" + +DISCORD_INVITE_LINK= +DISCORD_WEBHOOK_URL= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_REDIRECT_URI= +DISCORD_GUILD_ID= +EMBED_WEBHOOK_USERNAME="yougirl city Notifier" +EMBED_TITLE="New Player Whitelisted" +EMBED_COLOR=F5A9B8 + + +RCON_HOST= +RCON_PORT= +RCON_PASSWORD= +RCON_TIMEOUT= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0aa1db --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +vendor +composer.lock +db +.env diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..cb9f06e --- /dev/null +++ b/composer.json @@ -0,0 +1,7 @@ +{ + "require": { + "vlucas/phpdotenv": "*", + "thedudeguy/rcon": "^1.0", + "guzzlehttp/guzzle": "^7.10" + } +} diff --git a/public/403.php b/public/403.php new file mode 100644 index 0000000..62c25f0 --- /dev/null +++ b/public/403.php @@ -0,0 +1,27 @@ +load(); +?> + + + + + + + 403 Forbidden | <?= $_ENV['SITE_NAME'] ?> + + + '> + + +
+

403

+

Hey! You sneaky one, we can have you seeing this

+ Back to ! +
+ + diff --git a/public/404.php b/public/404.php new file mode 100644 index 0000000..b3c8624 --- /dev/null +++ b/public/404.php @@ -0,0 +1,27 @@ +load(); +?> + + + + + + + 404 Not Found | <?= $_ENV['SITE_NAME'] ?> + + + '> + + +
+

404

+

Oops! this page doesn't exist!

+ Back to ! +
+ + diff --git a/public/assets/style.css b/public/assets/style.css new file mode 100644 index 0000000..0ab12d2 --- /dev/null +++ b/public/assets/style.css @@ -0,0 +1,281 @@ +@import url(https://fonts.bunny.net/css2?family=Poppins:wght@400;600&family=Press+Start+2P&display=swap); + +:root { + --bg1: #2a003f; + --bg2: #001d2d; + --accent1: #ff7ad0; + --accent2: #65d6ff; + --text: #f4f4f7; + --subtext: #bbb; + --card-bg: rgba(20, 20, 30, 0.75); + --shadow: rgba(0, 0, 0, 0.6); +} + +/* Reset and Base */ +*, +::before, +::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: Poppins, sans-serif; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + color: var(--text); + line-height: 1.6; + background: radial-gradient(circle at top left, var(--bg1), transparent 70%), + radial-gradient(circle at bottom right, var(--bg2), transparent 70%), + #0a0a0f; + -webkit-font-smoothing: antialiased; +} + +/* Container */ +.container { + text-align: center; + background: var(--card-bg); + backdrop-filter: blur(16px); + border-radius: 20px; + padding: 32px 20px 28px; + box-shadow: 0 8px 32px var(--shadow); + max-width: 480px; + width: 100%; + animation: fadeIn 0.8s cubic-bezier(0.33, 1, 0.68, 1) both; +} + +/* Headings */ +h1, +h2 { + font-family: "Press Start 2P", monospace; + line-height: 1.3; + color: var(--text); + text-shadow: 0 0 8px rgba(255, 122, 208, 0.6); + margin-bottom: 16px; +} + +h1 { + font-size: clamp(20px, 5vw, 26px); +} + +h2 { + font-size: clamp(16px, 4vw, 22px); +} + +/* Text */ +p { + margin-bottom: 24px; + font-size: clamp(14px, 4vw, 16px); + color: var(--subtext); +} + +/* Links as buttons */ +a.btn { + display: inline-block; + padding: 14px 22px; + font-size: clamp(14px, 4vw, 16px); + font-weight: 600; + border-radius: 14px; + background: linear-gradient(90deg, var(--accent1), var(--accent2)); + background-size: 200% 200%; + color: #fff; + text-decoration: none; + box-shadow: + 0 0 12px rgba(255, 122, 208, 0.4), + 0 0 18px rgba(101, 214, 255, 0.3); + animation: pulse 2.8s ease-in-out infinite, gradientShift 6s ease infinite; + transition: transform 0.25s ease, box-shadow 0.25s ease; + min-width: 160px; +} + +a.btn:hover, +a.btn:focus { + transform: translateY(-2px) scale(1.07); + box-shadow: + 0 0 20px rgba(255, 122, 208, 0.7), + 0 0 28px rgba(101, 214, 255, 0.6); + outline: none; +} + +/* Input */ +input[type="text"] { + width: 80%; + max-width: 380px; + padding: 13px 15px; + font-size: 15px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 15, 20, 0.85); + color: #fff; + outline: none; + transition: all 0.25s ease; + backdrop-filter: blur(6px); + box-shadow: inset 0 0 8px rgba(255, 255, 255, 0.05); +} + +input[type="text"]:focus { + border-color: var(--accent1); + background: rgba(25, 25, 35, 0.9); + box-shadow: + 0 0 10px rgba(255, 122, 208, 0.4), + 0 0 18px rgba(101, 214, 255, 0.3); +} + +/* Button */ +button.btn { + all: unset; + display: inline-block; + padding: 14px 28px; + font-size: 16px; + font-weight: 600; + color: #fff; + border-radius: 14px; + cursor: pointer; + text-align: center; + background: linear-gradient(135deg, var(--accent1), var(--accent2)); + box-shadow: + 0 0 12px rgba(255, 122, 208, 0.3), + 0 0 20px rgba(101, 214, 255, 0.2), + inset 0 0 10px rgba(255, 255, 255, 0.05); + transition: all 0.25s ease; + min-width: 190px; +} + +button.btn:hover { + transform: translateY(-1px) scale(1.05); + box-shadow: + 0 0 20px rgba(255, 122, 208, 0.5), + 0 0 35px rgba(101, 214, 255, 0.4), + inset 0 0 15px rgba(255, 255, 255, 0.08); +} + +button.btn:active { + transform: scale(0.97); + box-shadow: 0 0 8px rgba(255, 122, 208, 0.4); +} + +/* Note */ +.note { + margin-top: 20px; + font-size: 12px; + color: #999; +} + +.note a { + color: var(--accent2); + text-decoration: none; + transition: color 0.2s ease, text-decoration 0.2s ease; +} + +.note a:hover { + color: var(--accent1); + text-decoration: underline; +} + +ul { + list-style: none; + padding: 0; + margin: 20px auto; + text-align: left; + max-width: 380px; +} + +ul li { + background: rgba(15, 15, 25, 0.7); + border: 1px solid rgba(255, 255, 255, 0.05); + border-left: 4px solid var(--accent1); + border-radius: 10px; + margin-bottom: 10px; + padding: 10px 14px; + font-size: 15px; + color: var(--text); + box-shadow: + 0 0 10px rgba(255, 122, 208, 0.15), + inset 0 0 6px rgba(255, 255, 255, 0.05); + transition: all 0.25s ease; + user-select: none; +} + +ul li::before { + content: "•"; + color: var(--accent1); + margin-right: 8px; + text-shadow: 0 0 6px var(--accent1); +} + +.platforms { + margin-bottom: 24px; +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +@keyframes gradientShift { + 0%, 100% { + background-position: 0 50%; + } + 50% { + background-position: 100% 50%; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 400px) { + .container { + padding: 24px 16px; + border-radius: 16px; + } +} +.error{ + color:var(--accent1); + font-weight:600; +} +.success{ + color:var(--accent2); + font-weight:600; +} +.footer{ +margin-top:40px; +padding:15px; +background:rgba(255,255,255,0.05); +border-radius:10px; +text-align:center; +font-size:14px; +color:#bbb +} +#mc_user{ +padding:10px; +width:80%; +border-radius:8px; +border:none; +background:#111; +color:#fff +} +#waitMsg{ + display:none; + color:#bbb; + font-size:14px; +} +#unlinkBtn{ + color:#ff7ad0; +} \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..08fb469 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/images/apple-touch-icon.png b/public/images/apple-touch-icon.png new file mode 100644 index 0000000..b90faa6 Binary files /dev/null and b/public/images/apple-touch-icon.png differ diff --git a/public/images/favicon-96x96.png b/public/images/favicon-96x96.png new file mode 100644 index 0000000..b2b6834 Binary files /dev/null and b/public/images/favicon-96x96.png differ diff --git a/public/images/favicon.svg b/public/images/favicon.svg new file mode 100644 index 0000000..fe0e7dd --- /dev/null +++ b/public/images/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/public/images/site.webmanifest b/public/images/site.webmanifest new file mode 100644 index 0000000..6dce252 --- /dev/null +++ b/public/images/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "tgirl city", + "short_name": "tgirl city", + "icons": [ + { + "src": "/images/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/images/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#F5A9B8", + "background_color": "#F5A9B8", + "display": "standalone" +} \ No newline at end of file diff --git a/public/images/web-app-manifest-192x192.png b/public/images/web-app-manifest-192x192.png new file mode 100644 index 0000000..9b10461 Binary files /dev/null and b/public/images/web-app-manifest-192x192.png differ diff --git a/public/images/web-app-manifest-512x512.png b/public/images/web-app-manifest-512x512.png new file mode 100644 index 0000000..29ea88a Binary files /dev/null and b/public/images/web-app-manifest-512x512.png differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..df3a435 --- /dev/null +++ b/public/index.php @@ -0,0 +1,31 @@ +load(); +?> + + + + + + <?= $_ENV['SITE_NAME'] ?> + + + + + + + '> + + +
+

+

The discord for transfems

+ Join Our Discord! +
(not a fetish server, no chasers allowed!)
+
+ + diff --git a/public/whitelist/index.php b/public/whitelist/index.php new file mode 100644 index 0000000..8000c55 --- /dev/null +++ b/public/whitelist/index.php @@ -0,0 +1,373 @@ +load(); + +$config = [ + 'SITE_NAME' => $_ENV['SITE_NAME'] ?? 'tgirl.city', + 'version' => $_ENV['VERSION'] ?? 'unknown', + 'discord_id' => $_ENV['DISCORD_CLIENT_ID'], + 'discord_secret' => $_ENV['DISCORD_CLIENT_SECRET'], + 'discord_redirect'=> $_ENV['DISCORD_REDIRECT_URI'], + 'discord_guild' => $_ENV['DISCORD_GUILD_ID'], + 'rcon_host' => $_ENV['RCON_HOST'], + 'rcon_port' => (int)$_ENV['RCON_PORT'], + 'rcon_pass' => $_ENV['RCON_PASSWORD'], + 'rcon_timeout' => (int)($_ENV['RCON_TIMEOUT'] ?? 1), + 'webhook_url' => $_ENV['DISCORD_WEBHOOK_URL'] ?? null, + 'embed_webhook_name' => $_ENV['EMBED_WEBHOOK_USERNAME'] ?? 'Whitelist Notifier', + 'embed_title' => $_ENV['EMBED_TITLE'] ?? 'New Player Whitelisted', + 'embed_color' => isset($_ENV['EMBED_COLOR']) ? hexdec($_ENV['EMBED_COLOR']) : 0x00AEEF +]; + +$db_path = $_SERVER['DOCUMENT_ROOT'] . '/../db/whitelist.db'; +if (!file_exists(dirname($db_path))) mkdir(dirname($db_path), 0755, true); + +// db +//TODO: Change from sqlite to mysql, mainly because sqlite does not scale well, nore does it handle concurrency well. Planned deprecation in future. Build 1.1.0 +try { + $db = new PDO('sqlite:' . $db_path, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_PERSISTENT => true, + ]); + $db->exec("CREATE TABLE IF NOT EXISTS whitelist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT NOT NULL, + discord_username TEXT NOT NULL, + mc_username TEXT NOT NULL, + platform TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + )"); +} catch (Exception $e) { + die('Database error: ' . htmlspecialchars($e->getMessage())); +} + +$http = new Client([ + 'timeout' => 5, + 'http_errors' => false +]); + +if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off') { + header('Location: https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], true, 301); + exit; +} + +function render_header($title='Whitelist'){ + global $config; + echo " + + + + ".htmlspecialchars($title)." + +

".htmlspecialchars($title)."

"; +} +function render_footer(){ + global $config; + echo '
'; +} +function render_error($msg){echo '

'.htmlspecialchars($msg).'

';} +function render_success($msg){echo '

'.htmlspecialchars($msg).'

';} + +// rcon +function rcon_send($host, $port, $pass, $cmd, $timeout = 1) { + $rcon = new Rcon($host, $port, $pass, $timeout); + + if (!$rcon->connect()) { + return ['ok' => false, 'err' => 'Unable to connect']; + } + + $response = $rcon->sendCommand($cmd); + + return [ + 'ok' => true, + 'out' => $response ?: '' + ]; +} + +// OAUTH +function http_json($client, $url, $options = []) { + try { + $res = $client->request($options['method'] ?? 'GET', $url, $options); + $body = (string)$res->getBody(); + return $body ? json_decode($body, true) : null; + } catch (Exception $e) { + return null; + } +} + +function discord_exchange_code($client, $id, $secret, $redirect, $code) { + return http_json($client, 'https://discord.com/api/oauth2/token', [ + 'method' => 'POST', + 'form_params' => [ + 'client_id' => $id, + 'client_secret' => $secret, + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $redirect + ], + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded' + ] + ]); +} + +function discord_get_user($client, $token) { + return http_json($client, 'https://discord.com/api/users/@me', [ + 'headers' => [ + 'Authorization' => "Bearer $token" + ] + ]); +} + +function discord_get_guilds($client, $token) { + return http_json($client, 'https://discord.com/api/users/@me/guilds', [ + 'headers' => [ + 'Authorization' => "Bearer $token" + ] + ]); +} + +// discord webhook +function send_discord_webhook($client, $url, $discordUser, $mcUser, $discordId = null) { + global $config; + + $mention = $discordId ? "<@{$discordId}>" : $discordUser; + + $payload = [ + 'username' => $config['embed_webhook_name'], + 'embeds' => [[ + 'title' => $config['embed_title'], + 'color' => $config['embed_color'], + 'fields' => [ + [ + 'name' => 'Discord User', + 'value' => $mention, + 'inline' => true + ], + [ + 'name' => 'Minecraft Username', + 'value' => $mcUser, + 'inline' => true + ] + ], + 'timestamp' => date('c') + ]] + ]; + + try { + $client->post($url, [ + 'json' => $payload + ]); + } catch (Exception $e) { + } +} + +if(($_GET['action'] ?? '') === 'logout'){ + session_destroy(); + header("Location: ".$_SERVER['PHP_SELF']); + exit; +} + +if(($_GET['action'] ?? '') === 'unlink'){ + if(!isset($_SESSION['access_token'])){ + header("Location: ".$_SERVER['PHP_SELF']); exit; + } + $access_token = $_SESSION['access_token']; + $user = $_SESSION['discord_user'] ?? discord_get_user($http, $access_token); + if(!$user || !isset($user['id'])){ + render_header('Unlink Error'); + render_error('Unable to fetch your Discord info.'); + render_footer(); exit; + } + $stmt = $db->prepare("SELECT mc_username, platform FROM whitelist WHERE discord_id = ?"); + $stmt->execute([$user['id']]); + $entries = $stmt->fetchAll(PDO::FETCH_ASSOC); + foreach($entries as $e){ + $cmd = ($e['platform']==='bedrock') ? "fwhitelist remove .{$e['mc_username']}" : "whitelist remove {$e['mc_username']}"; + rcon_send($config['rcon_host'],$config['rcon_port'],$config['rcon_pass'],$cmd,$config['rcon_timeout']); + } + $db->prepare("DELETE FROM whitelist WHERE discord_id = ?")->execute([$user['id']]); + session_destroy(); + render_header('Unlinked'); + render_success('Your whitelist entry and database record have been removed.'); + echo '

Return to start

'; + render_footer(); exit; +} + +if(($_GET['action'] ?? '') === 'remove' && isset($_GET['name'])){ + if(!isset($_SESSION['access_token'])){ + header("Location: ".$_SERVER['PHP_SELF']); exit; + } + $access_token = $_SESSION['access_token']; + $user = $_SESSION['discord_user'] ?? discord_get_user($http, $access_token); + if(!$user || !isset($user['id'])){ + render_header('Remove Error'); + render_error('Unable to fetch your Discord info.'); + render_footer(); exit; + } + $name = trim($_GET['name']); + if($name === ''){ + render_header('Error'); + render_error('No name provided.'); + render_footer(); exit; + } + + $stmt = $db->prepare("SELECT platform FROM whitelist WHERE discord_id = ? AND mc_username = ?"); + $stmt->execute([$user['id'], $name]); + $entry = $stmt->fetch(PDO::FETCH_ASSOC); + if(!$entry){ + render_header('Not Found'); + render_error('That name is not on your whitelist.'); + render_footer(); exit; + } + + $cmd = ($entry['platform']==='bedrock') ? "fwhitelist remove .$name" : "whitelist remove $name"; + rcon_send($config['rcon_host'],$config['rcon_port'],$config['rcon_pass'],$cmd,$config['rcon_timeout']); + $db->prepare("DELETE FROM whitelist WHERE discord_id = ? AND mc_username = ?")->execute([$user['id'], $name]); + + render_header('Removed'); + render_success("$name has been removed from the whitelist."); + echo '

Return to main page

'; + render_footer(); exit; +} + +if(isset($_GET['code']) && !isset($_SESSION['access_token'])){ + global $http; + $token = discord_exchange_code($http, + $config['discord_id'],$config['discord_secret'],$config['discord_redirect'],$_GET['code'] + ); + if(!$token || !isset($token['access_token'])){ + render_header('Discord sign-in error'); + render_error('Failed to get access token.'); + render_footer(); exit; + } + $_SESSION['access_token'] = $token['access_token']; + $_SESSION['discord_user'] = discord_get_user($http, $token['access_token']); + $_SESSION['discord_guilds'] = discord_get_guilds($http, $token['access_token']); + header("Location: ".$_SERVER['PHP_SELF']); exit; +} + +// check auth +if(!isset($_SESSION['access_token'])){ + render_header('Sign in with Discord'); + $url = 'https://discord.com/api/oauth2/authorize?response_type=code&client_id=' + .urlencode($config['discord_id']) + .'&scope=identify+guilds&redirect_uri=' + .urlencode($config['discord_redirect']); + echo '

You must sign in with Discord to verify you are in our server.

'; + echo 'Sign in with Discord'; + render_footer(); exit; +} + +$user = discord_get_user($http, $_SESSION['access_token']); +$guilds = $_SESSION['discord_guilds'] ?? discord_get_guilds($http, $_SESSION['access_token']); +$in_guild = false; +foreach(($guilds ?? []) as $g) if(($g['id'] ?? '') === $config['discord_guild']) $in_guild = true; +if(!$in_guild){ + render_header('Not in server'); + render_error('You are not a member of our Discord server.'); + echo '

Logged in as '.htmlspecialchars($user['username']).' Sign out

'; + render_footer(); exit; +} +if(empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16)); + +$rate_limit_seconds = 20; +$allowed_name_pattern = '/^[A-Za-z0-9_\\-]{2,32}$/'; + +if($_SERVER['REQUEST_METHOD'] === 'POST'){ + if(!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')){ + render_header('Security error'); + render_error('Invalid session token. Try again.'); + render_footer(); exit; + } + if(isset($_SESSION['last_submit']) && (time()-$_SESSION['last_submit']) < $rate_limit_seconds){ + render_header('Slow down'); + render_error("Please wait {$rate_limit_seconds} seconds between attempts."); + render_footer(); exit; + } + + $username = trim($_POST['mc_user'] ?? ''); + $platform = $_POST['platform'] ?? 'java'; + if(!preg_match($allowed_name_pattern,$username)){ + render_header('Invalid username'); + render_error('Username must be 2-32 characters (letters, numbers, _, -).'); + render_footer(); exit; + } + + if($platform === 'bedrock' && $username[0] !== '.') $username = '.' . $username; + + $exists = $db->prepare("SELECT 1 FROM whitelist WHERE mc_username=?"); + $exists->execute([$username]); + if($exists->fetchColumn()){ + render_header('Already Whitelisted'); + render_success(htmlspecialchars(ltrim($username,'.')).' is already on the whitelist!'); + echo '

Signed in as '.htmlspecialchars($user['username']).' - Sign out

'; + render_footer(); exit; + } + + $cmd = $platform==='bedrock' ? "fwhitelist add $username" : "whitelist add $username"; + $result = rcon_send($config['rcon_host'],$config['rcon_port'],$config['rcon_pass'],$cmd,$config['rcon_timeout']); + $_SESSION['last_submit']=time(); + + render_header('Whitelist Result'); + $display = ltrim($username,'.'); + if($result['ok']){ + $stmt = $db->prepare("INSERT INTO whitelist (discord_id, discord_username, mc_username, platform) VALUES (?, ?, ?, ?)"); + $stmt->execute([$user['id'], $user['username'], $display, $platform]); + if (!empty($config['webhook_url'])) { + send_discord_webhook($http, $config['webhook_url'], $user['username'], $display, $user['id']); + } + render_success("$display has been successfully added to the whitelist!"); + } else { + render_error("Error: Unable to confirm $display on the whitelist. Try again or contact an admin."); + } + echo '

Signed in as '.htmlspecialchars($user['username']).' - Sign out

'; + render_footer(); exit; +} + +// page index +render_header('Request Minecraft Whitelist'); +echo '

Signed in as '.htmlspecialchars($user['username']).' - verified member.

'; + +$stmt = $db->prepare("SELECT mc_username, platform, timestamp FROM whitelist WHERE discord_id = ? ORDER BY timestamp DESC"); +$stmt->execute([$user['id']]); +$entries = $stmt->fetchAll(PDO::FETCH_ASSOC); +if($entries){ + echo "

Your whitelisted accounts:

"; +} +?> + +
+

+
+ + +
+ +

+

This can take a few seconds to work…

+

+ Sign out | + Unlink account +

+
+ + + diff --git a/public/whitelist/js/main.js b/public/whitelist/js/main.js new file mode 100644 index 0000000..543bc20 --- /dev/null +++ b/public/whitelist/js/main.js @@ -0,0 +1,36 @@ +document.addEventListener("DOMContentLoaded", () => { + const unlinkBtn = document.getElementById("unlinkBtn"); + if (unlinkBtn) { + unlinkBtn.addEventListener("click", (e) => { + e.preventDefault(); + if (confirm("Are you sure you want to unlink? This will remove your whitelist and delete your entry from our database.")) { + window.location.href = "?action=unlink"; + } + }); + } + const whitelistForm = document.querySelector("form"); + if (whitelistForm) { + whitelistForm.addEventListener("submit", () => { + const btn = document.getElementById("whitelistBtn"); + const msg = document.getElementById("waitMsg"); + if (btn && msg) { + btn.disabled = true; + btn.textContent = "Please wait…"; + msg.style.display = "block"; + } + }); + } + const list = document.getElementById("whitelist"); + if (list) { + list.addEventListener("dblclick", (ev) => { + const li = ev.target.closest("li[data-name]"); + if (!li) return; + const name = li.getAttribute("data-name"); + if (confirm(`Remove ${name} from whitelist?`)) { + li.style.opacity = 0.5; + li.textContent = `Removing ${name}…`; + window.location.href = `?action=remove&name=${encodeURIComponent(name)}`; + } + }); + } +}); \ No newline at end of file