""" GhostWipe HTML report generator. Builds a single self-contained HTML file (no external resources, no required JavaScript) that lets a moderator browse every message GhostWipe deleted for a departing member. All content — stats, avatar, channel list, and every message — is rendered server-side directly into the HTML/CSS, using a pure CSS radio/label technique for channel switching. This way the report displays correctly even in minimal viewers that don't execute scripts or load remote network resources (a common failure mode for local file:// HTML viewers). A small script is layered on top purely as a progressive enhancement for live search filtering; if it never runs, everything is still fully visible via the CSS tab switcher. """ from __future__ import annotations import html as html_lib from typing import Any, Dict, List, Optional STYLE = r""" :root { --bg: #0e0f13; --bg-alt: #15171d; --panel: #1a1d24; --panel-alt: #20232b; --border: #2a2e38; --text: #e7e9ee; --text-dim: #9096a3; --text-faint: #5b6270; --accent: #8b5cf6; --accent-soft: rgba(139, 92, 246, 0.15); --danger: #f04747; --warn: #faa61a; --ok: #3ba55d; --ruby: #e0115f; --radius: 10px; --shadow: 0 8px 24px rgba(0,0,0,0.35); } @media (prefers-color-scheme: light) { :root { --bg: #f4f5f8; --bg-alt: #ffffff; --panel: #ffffff; --panel-alt: #f0f1f5; --border: #e1e3e9; --text: #1a1d24; --text-dim: #565c6a; --text-faint: #8b909c; --accent: #7c3aed; --accent-soft: rgba(124, 58, 237, 0.10); --shadow: 0 8px 24px rgba(0,0,0,0.08); } } :root[data-theme="dark"] { --bg: #0e0f13; --bg-alt: #15171d; --panel: #1a1d24; --panel-alt: #20232b; --border: #2a2e38; --text: #e7e9ee; --text-dim: #9096a3; --text-faint: #5b6270; --accent: #8b5cf6; --accent-soft: rgba(139, 92, 246, 0.15); --shadow: 0 8px 24px rgba(0,0,0,0.35); } :root[data-theme="light"] { --bg: #f4f5f8; --bg-alt: #ffffff; --panel: #ffffff; --panel-alt: #f0f1f5; --border: #e1e3e9; --text: #1a1d24; --text-dim: #565c6a; --text-faint: #8b909c; --accent: #7c3aed; --accent-soft: rgba(124, 58, 237, 0.10); --shadow: 0 8px 24px rgba(0,0,0,0.08); } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: "gg sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 14px; } #app { display: flex; flex-direction: column; min-height: 100vh; } /* Header */ #header { display: flex; align-items: center; gap: 16px; padding: 14px 20px; background: var(--bg-alt); border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap; } .avatar, .avatar-fallback { width: 48px; height: 48px; border-radius: 50%; flex-shrink: 0; border: 2px solid var(--border); background: var(--panel-alt); } .avatar-fallback { display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 18px; color: var(--accent); } #header .id-block { min-width: 180px; } #header .username { font-weight: 700; font-size: 16px; } #header .userid { color: var(--text-faint); font-size: 12px; } .badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; } .badge-left { background: rgba(120,120,130,0.18); color: #b7bcc7; } .badge-kicked { background: rgba(250,166,26,0.16); color: var(--warn); } .badge-banned { background: rgba(240,71,71,0.16); color: var(--danger); } .badge-manual { background: var(--accent-soft); color: var(--accent); } .badge-dryrun { background: var(--accent-soft); color: var(--accent); margin-left: 6px; } #header .stats { display: flex; gap: 22px; margin-left: auto; flex-wrap: wrap; } #header .stat { text-align: center; } #header .stat .num { font-size: 20px; font-weight: 700; } #header .stat .lbl { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .05em; } #header .meta-row { width: 100%; font-size: 12px; color: var(--text-dim); margin-top: 2px; } /* Body layout */ #body { flex: 1; display: flex; min-height: 0; } #sidebar { width: 280px; flex-shrink: 0; background: var(--bg-alt); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; } #sidebar .sidebar-title { padding: 12px 16px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); font-weight: 700; } #channel-list { list-style: none; margin: 0; padding: 4px 8px; overflow-y: auto; flex: 1; } .ch-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 10px; border-radius: 8px; cursor: pointer; margin-bottom: 2px; color: var(--text-dim); transition: background .1s ease; } .ch-item:hover { background: var(--panel-alt); color: var(--text); } .ch-item .ch-name { display: flex; align-items: center; gap: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ch-item .ch-icon { opacity: .6; flex-shrink: 0; } .ch-item .ch-count { background: var(--panel-alt); color: var(--text-faint); font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; flex-shrink: 0; } .ch-item.skipped { opacity: .6; } .ch-item .skip-tag { font-size: 10px; color: var(--danger); } #search-wrap { padding: 10px; border-top: 1px solid var(--border); flex-shrink: 0; } #search-wrap input { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel-alt); color: var(--text); font-size: 13px; } #search-wrap input:focus { outline: 2px solid var(--accent); } .js-only-note { display: none; font-size: 10px; color: var(--text-faint); margin-top: 6px; } /* Main pane */ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .panel { display: none; flex-direction: column; flex: 1; min-height: 0; } .panel-header { padding: 12px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; flex-shrink: 0; } .panel-header h2 { margin: 0; font-size: 15px; font-weight: 700; } .count-pill { background: var(--panel-alt); border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px; font-size: 12px; color: var(--text-dim); } .panel-body { flex: 1; overflow-y: auto; padding: 16px 20px; } .msg { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 10px; } .msg.hidden-by-search { display: none; } .msg .msg-top { display: flex; justify-content: space-between; color: var(--text-faint); font-size: 11px; margin-bottom: 6px; } .msg .msg-content { white-space: pre-wrap; word-break: break-word; line-height: 1.45; } .msg .msg-content.redacted { color: var(--text-faint); font-style: italic; } .attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } .attachment-chip { display: flex; align-items: center; gap: 6px; background: var(--panel-alt); border: 1px solid var(--border); border-radius: 8px; padding: 5px 10px; font-size: 12px; color: var(--text-dim); text-decoration: none; max-width: 260px; } .attachment-chip:hover { border-color: var(--accent); color: var(--text); } .attachment-img { max-width: 320px; max-height: 240px; border-radius: 8px; margin-top: 8px; display: block; border: 1px solid var(--border); } .sticker-chip { background: var(--accent-soft); color: var(--accent); border-radius: 8px; padding: 4px 9px; font-size: 12px; margin-top: 6px; display: inline-block; } .empty-state { text-align: center; color: var(--text-faint); padding: 60px 20px; } .skip-banner { background: rgba(250,166,26,0.10); border: 1px solid rgba(250,166,26,0.3); color: var(--warn); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; font-size: 13px; } #footer { padding: 8px 20px; border-top: 1px solid var(--border); color: var(--text-faint); font-size: 11px; text-align: center; flex-shrink: 0; } .brand-ruby { color: var(--ruby); font-weight: 700; } ::-webkit-scrollbar { width: 10px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 6px; } ::-webkit-scrollbar-track { background: transparent; } @media (max-width: 700px) { html, body { font-size: 15px; } #header { padding: 12px 14px; gap: 10px; } .avatar, .avatar-fallback { width: 40px; height: 40px; } #header .username { font-size: 14px; } #header .id-block { min-width: 0; } #header .stats { order: 3; width: 100%; margin-left: 0; justify-content: space-around; gap: 10px; } #header .stat .num { font-size: 16px; } #header .meta-row { order: 4; } /* Stack sidebar above main content, and turn the channel list into a horizontally-scrolling strip of chips instead of a tall vertical list — the classic mobile pattern for a channel/tab picker. */ #body { flex-direction: column; } #sidebar { width: 100%; max-height: none; border-right: none; border-bottom: 1px solid var(--border); } #sidebar .sidebar-title { padding: 10px 14px 4px; } #channel-list { display: flex; flex-direction: row; flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; gap: 6px; padding: 4px 10px; } .ch-item { flex: 0 0 auto; margin-bottom: 0; } .ch-item .ch-name { white-space: nowrap; } #search-wrap { padding: 8px 10px; } #main { width: 100%; } .panel-header { padding: 10px 14px; } .panel-body { padding: 12px 14px; } .attachment-img { max-width: 100%; } } """ SEARCH_SCRIPT = r""" (function () { var box = document.getElementById('search'); var note = document.getElementById('js-only-note'); if (!box) return; if (note) note.style.display = 'block'; box.addEventListener('input', function () { var term = box.value.toLowerCase(); var msgs = document.querySelectorAll('.msg[data-content]'); for (var i = 0; i < msgs.length; i++) { var el = msgs[i]; var match = !term || el.getAttribute('data-content').indexOf(term) !== -1; el.classList.toggle('hidden-by-search', !match); } }); })(); """ def _badge_class(reason: str) -> str: return { "left": "badge-left", "kicked": "badge-kicked", "banned": "badge-banned", "manual": "badge-manual", }.get(reason, "badge-left") def _badge_label(reason: str) -> str: return { "left": "Left", "kicked": "Kicked", "banned": "Banned", "manual": "Manual", }.get(reason, reason.title()) CHANNEL_ICON = {"text": "#", "thread": "\U0001F9F5", "voice": "\U0001F50A"} def _esc(value: Any) -> str: return html_lib.escape(str(value), quote=True) def _render_message(msg: Dict[str, Any], redact: bool) -> str: content = msg.get("content") or "" search_key = _esc(content.lower()) if not redact else "" parts = [f'