enrichUsernames() was only invoked once at startup, so any rooms that arrived via a later delta /sync stayed pinned to their raw t2_xxx identifier — which the dashboard then rendered as a bare Matrix-style ID. Call enrichUsernames() at the tail of syncOnce so new rooms get resolved to real Reddit usernames before the UI reads them. Also pass the unformatted username to openConversation() and format the modal title in the frontend, so lookups by username keep working after the display sanitisation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4121 lines
125 KiB
HTML
4121 lines
125 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Reddit Bot · Console</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link rel="stylesheet"
|
||
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,500&family=Sora:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap">
|
||
<style>
|
||
:root {
|
||
/* Paper & ink */
|
||
--paper: #f4ede1;
|
||
--paper-2: #ebe0cc;
|
||
--card: #faf6ee;
|
||
--card-2: #f1e9da;
|
||
--ink: #3d2e26;
|
||
--ink-soft: #6e5c50;
|
||
--ink-faint: #a6907e;
|
||
--rule: #e5d9c7;
|
||
--rule-soft: #ede3d2;
|
||
|
||
/* Accents — dusty, never neon */
|
||
--terracotta: #c97b5e;
|
||
--terracotta-deep: #a85f43;
|
||
--terracotta-soft: #f3d9cd;
|
||
--sage: #8fa67e;
|
||
--sage-deep: #6f8b5f;
|
||
--sage-soft: #dde5d2;
|
||
--honey: #d4a574;
|
||
--honey-soft: #f5e6d0;
|
||
--clay: #b85c5c;
|
||
--clay-soft: #f1d2d2;
|
||
--lilac: #9a8fc0;
|
||
--lilac-soft: #e2dcef;
|
||
--sky: #7fa6b8;
|
||
--sky-soft: #d6e4ea;
|
||
|
||
/* Mocha dark surface (log/console) */
|
||
--mocha: #2a2218;
|
||
--mocha-2: #3a2f23;
|
||
--cream-on-mocha: #ebdcc4;
|
||
--cream-on-mocha-soft: #b9a888;
|
||
|
||
/* Shadows (warm tinted, layered) */
|
||
--shadow-sm: 0 1px 2px rgba(82, 56, 41, .06);
|
||
--shadow-md: 0 4px 14px -4px rgba(82, 56, 41, .12), 0 2px 4px rgba(82, 56, 41, .05);
|
||
--shadow-lg: 0 20px 50px -20px rgba(82, 56, 41, .25), 0 8px 20px -8px rgba(82, 56, 41, .10);
|
||
--shadow-inset: inset 0 1px 0 rgba(255, 252, 244, .8), inset 0 0 0 1px var(--rule);
|
||
|
||
/* Radii */
|
||
--r-xs: 6px;
|
||
--r-sm: 10px;
|
||
--r-md: 14px;
|
||
--r-lg: 20px;
|
||
--r-xl: 28px;
|
||
--r-pill: 999px;
|
||
|
||
/* Type */
|
||
--f-display: 'Fraunces', 'Iowan Old Style', Georgia, serif;
|
||
--f-body: 'Sora', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
|
||
--f-mono: 'Geist Mono', 'JetBrains Mono', Menlo, monospace;
|
||
}
|
||
|
||
*, *::before, *::after {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html {
|
||
scroll-behavior: smooth;
|
||
}
|
||
|
||
body {
|
||
font-family: var(--f-body);
|
||
color: var(--ink);
|
||
min-height: 100vh;
|
||
background:
|
||
radial-gradient(1100px 540px at 92% -10%, rgba(201, 123, 94, .18), transparent 62%),
|
||
radial-gradient(900px 480px at -8% 108%, rgba(143, 166, 126, .16), transparent 62%),
|
||
radial-gradient(700px 400px at 50% 50%, rgba(154, 143, 192, .05), transparent 70%),
|
||
linear-gradient(180deg, #f4ede1 0%, #efe5d2 100%);
|
||
background-attachment: fixed;
|
||
letter-spacing: -0.005em;
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
}
|
||
|
||
/* Paper grain texture overlay */
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
opacity: .35;
|
||
mix-blend-mode: multiply;
|
||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.24 0 0 0 0 0.18 0 0 0 0 0.15 0 0 0 0.45 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E");
|
||
}
|
||
|
||
.container {
|
||
max-width: 1240px;
|
||
margin: 0 auto;
|
||
padding: 28px 32px 80px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* Selection */
|
||
::selection {
|
||
background: var(--terracotta-soft);
|
||
color: var(--terracotta-deep);
|
||
}
|
||
|
||
/* ───── Toolbar ───── */
|
||
header.toolbar {
|
||
position: sticky;
|
||
top: 14px;
|
||
z-index: 50;
|
||
max-width: 1240px;
|
||
margin: 14px auto 0;
|
||
padding: 14px 22px;
|
||
background: rgba(250, 246, 238, .82);
|
||
backdrop-filter: saturate(140%) blur(14px);
|
||
-webkit-backdrop-filter: saturate(140%) blur(14px);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-pill);
|
||
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 252, 244, .9);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18px;
|
||
}
|
||
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding-left: 4px;
|
||
}
|
||
|
||
.brand-mark {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: var(--r-pill);
|
||
background:
|
||
radial-gradient(circle at 30% 30%, #f3d9cd 0%, #c97b5e 70%, #a85f43 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fff8ef;
|
||
font-family: var(--f-display);
|
||
font-style: italic;
|
||
font-weight: 600;
|
||
font-size: 18px;
|
||
box-shadow: inset 0 -3px 6px rgba(0, 0, 0, .12), 0 4px 10px -4px rgba(201, 123, 94, .55);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.brand-text {
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.brand-title {
|
||
font-family: var(--f-display);
|
||
font-weight: 500;
|
||
font-size: 19px;
|
||
letter-spacing: -0.012em;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.brand-sub {
|
||
font-size: 11px;
|
||
color: var(--ink-faint);
|
||
font-style: italic;
|
||
font-family: var(--f-display);
|
||
font-weight: 400;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.toolbar-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.header-controls {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
/* ───── Status badges ───── */
|
||
.status-badge {
|
||
padding: 6px 14px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.status-badge::before {
|
||
content: '';
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: var(--r-pill);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.status-running {
|
||
background: var(--sage-soft);
|
||
color: var(--sage-deep);
|
||
border-color: rgba(143, 166, 126, .35);
|
||
}
|
||
.status-running::before {
|
||
background: var(--sage-deep);
|
||
box-shadow: 0 0 0 4px rgba(143, 166, 126, .25);
|
||
animation: ping 1.8s ease-out infinite;
|
||
}
|
||
|
||
.status-stopped {
|
||
background: var(--card-2);
|
||
color: var(--ink-soft);
|
||
border-color: var(--rule);
|
||
}
|
||
.status-stopped::before {
|
||
background: var(--ink-faint);
|
||
}
|
||
|
||
.status-dry-run {
|
||
background: var(--honey-soft);
|
||
color: #8a6432;
|
||
border-color: rgba(212, 165, 116, .4);
|
||
}
|
||
.status-dry-run::before {
|
||
background: var(--honey);
|
||
}
|
||
|
||
@keyframes ping {
|
||
0% { box-shadow: 0 0 0 0 rgba(143, 166, 126, .55); }
|
||
70% { box-shadow: 0 0 0 8px rgba(143, 166, 126, 0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(143, 166, 126, 0); }
|
||
}
|
||
|
||
/* ───── Buttons ───── */
|
||
.control-btn,
|
||
.settings-btn,
|
||
.refresh-btn,
|
||
.btn-primary,
|
||
.btn-secondary,
|
||
.approve-btn,
|
||
.reject-btn,
|
||
.dismiss-btn,
|
||
.send-btn,
|
||
.suggest-btn,
|
||
.clear-btn,
|
||
.user-search button {
|
||
font-family: var(--f-body);
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
transition: transform .15s ease, background .2s ease, box-shadow .2s ease, color .2s ease;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.control-btn {
|
||
padding: 10px 20px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.control-btn.start {
|
||
background: var(--sage);
|
||
color: #fff;
|
||
box-shadow: 0 6px 16px -6px rgba(111, 139, 95, .55), inset 0 -2px 0 rgba(0, 0, 0, .08);
|
||
}
|
||
|
||
.control-btn.start:hover {
|
||
background: var(--sage-deep);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.control-btn.stop {
|
||
background: var(--clay);
|
||
color: #fff;
|
||
box-shadow: 0 6px 16px -6px rgba(184, 92, 92, .55), inset 0 -2px 0 rgba(0, 0, 0, .08);
|
||
}
|
||
|
||
.control-btn.stop:hover {
|
||
background: #9b4949;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.settings-btn {
|
||
padding: 9px 14px;
|
||
border-radius: var(--r-pill);
|
||
background: var(--card);
|
||
color: var(--ink-soft);
|
||
border-color: var(--rule);
|
||
font-size: 13px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.settings-btn:hover {
|
||
background: var(--card-2);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.refresh-btn {
|
||
padding: 7px 13px;
|
||
border-radius: var(--r-pill);
|
||
background: var(--card);
|
||
color: var(--ink-soft);
|
||
border-color: var(--rule);
|
||
font-size: 12px;
|
||
box-shadow: var(--shadow-sm);
|
||
margin-left: auto;
|
||
}
|
||
|
||
.refresh-btn:hover {
|
||
background: var(--card-2);
|
||
color: var(--ink);
|
||
}
|
||
|
||
/* ───── Section eyebrows ───── */
|
||
.section {
|
||
margin-top: 38px;
|
||
}
|
||
|
||
.section:first-of-type {
|
||
margin-top: 32px;
|
||
}
|
||
|
||
.section-eyebrow {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.section-eyebrow .num {
|
||
font-family: var(--f-display);
|
||
font-weight: 500;
|
||
font-size: 11px;
|
||
letter-spacing: 0.18em;
|
||
color: var(--ink-faint);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.section-eyebrow .title {
|
||
font-family: var(--f-display);
|
||
font-style: italic;
|
||
font-weight: 400;
|
||
font-size: 15px;
|
||
color: var(--ink-soft);
|
||
}
|
||
|
||
.section-eyebrow .rule {
|
||
flex: 1;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, var(--rule) 0%, transparent 100%);
|
||
}
|
||
|
||
/* ───── Stats ───── */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: 1.4fr 1fr 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.stats-grid {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
.stat-card {
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-lg);
|
||
padding: 22px 22px 20px;
|
||
box-shadow: var(--shadow-sm);
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: transform .2s ease, box-shadow .2s ease;
|
||
}
|
||
|
||
.stat-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
|
||
.stat-card h3 {
|
||
font-family: var(--f-body);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-faint);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.stat-card .value {
|
||
font-family: var(--f-display);
|
||
font-weight: 500;
|
||
font-size: 44px;
|
||
letter-spacing: -0.025em;
|
||
line-height: 1;
|
||
color: var(--ink);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.stat-card .subtext {
|
||
margin-top: 10px;
|
||
font-size: 12px;
|
||
color: var(--ink-soft);
|
||
}
|
||
|
||
.stat-card.highlight {
|
||
background:
|
||
radial-gradient(120% 100% at 0% 0%, var(--terracotta-soft) 0%, transparent 55%),
|
||
var(--card);
|
||
border-color: rgba(201, 123, 94, .35);
|
||
}
|
||
|
||
.stat-card.highlight::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: -40px;
|
||
right: -40px;
|
||
width: 160px;
|
||
height: 160px;
|
||
border-radius: var(--r-pill);
|
||
background: radial-gradient(circle, rgba(201, 123, 94, .22) 0%, transparent 70%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.stat-card.highlight h3 {
|
||
color: var(--terracotta-deep);
|
||
}
|
||
|
||
.stat-card.highlight .value {
|
||
color: var(--terracotta-deep);
|
||
font-size: 56px;
|
||
font-style: italic;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.stat-card.highlight .subtext {
|
||
color: var(--terracotta-deep);
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* ───── Panels ───── */
|
||
.panels {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 18px;
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.panels {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.panel {
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-lg);
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.panel-header,
|
||
.panel-header-row {
|
||
padding: 16px 22px;
|
||
background: linear-gradient(180deg, var(--card-2) 0%, transparent 100%);
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
font-family: var(--f-display);
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
letter-spacing: -0.005em;
|
||
color: var(--ink);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.panel-header-row .panel-title {
|
||
font-family: var(--f-display);
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
color: var(--ink);
|
||
letter-spacing: -0.005em;
|
||
}
|
||
|
||
.panel-content {
|
||
max-height: 420px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* ───── Activity ───── */
|
||
.activity-item {
|
||
padding: 14px 22px;
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
display: flex;
|
||
gap: 14px;
|
||
align-items: flex-start;
|
||
transition: background .2s ease;
|
||
}
|
||
|
||
.activity-item:hover {
|
||
background: var(--card-2);
|
||
}
|
||
|
||
.activity-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.activity-icon {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: var(--r-pill);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
flex-shrink: 0;
|
||
border: 1px solid var(--rule);
|
||
}
|
||
|
||
.activity-icon.sent {
|
||
background: var(--sage-soft);
|
||
color: var(--sage-deep);
|
||
border-color: rgba(143, 166, 126, .35);
|
||
}
|
||
|
||
.activity-icon.skipped {
|
||
background: var(--card-2);
|
||
color: var(--ink-faint);
|
||
}
|
||
|
||
.activity-icon.response {
|
||
background: var(--lilac-soft);
|
||
color: #6f6395;
|
||
border-color: rgba(154, 143, 192, .35);
|
||
}
|
||
|
||
.activity-icon.error {
|
||
background: var(--clay-soft);
|
||
color: var(--clay);
|
||
border-color: rgba(184, 92, 92, .35);
|
||
}
|
||
|
||
.activity-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.activity-message {
|
||
font-size: 14px;
|
||
color: var(--ink);
|
||
word-wrap: break-word;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.activity-time {
|
||
font-size: 11px;
|
||
color: var(--ink-faint);
|
||
margin-top: 5px;
|
||
font-family: var(--f-mono);
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.activity-thumb {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: var(--r-sm);
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
background: var(--card-2);
|
||
cursor: pointer;
|
||
transition: opacity .2s, transform .2s;
|
||
border: 1px solid var(--rule);
|
||
}
|
||
|
||
.activity-thumb:hover {
|
||
opacity: 0.85;
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
/* ───── User search ───── */
|
||
.user-search {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 12px 22px;
|
||
background: var(--card-2);
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
}
|
||
|
||
.user-search input {
|
||
flex: 1;
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
color: var(--ink);
|
||
padding: 9px 14px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
font-family: var(--f-body);
|
||
outline: none;
|
||
transition: border-color .2s, box-shadow .2s;
|
||
}
|
||
|
||
.user-search input::placeholder {
|
||
color: var(--ink-faint);
|
||
}
|
||
|
||
.user-search input:focus {
|
||
border-color: var(--terracotta);
|
||
box-shadow: 0 0 0 3px var(--terracotta-soft);
|
||
}
|
||
|
||
.user-search button {
|
||
background: var(--ink);
|
||
border: 1px solid var(--ink);
|
||
color: var(--card);
|
||
padding: 9px 16px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.02em;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.user-search button:hover {
|
||
background: #2a1f1a;
|
||
}
|
||
|
||
.user-lookup-results {
|
||
display: none;
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
background: var(--card-2);
|
||
}
|
||
|
||
.user-lookup-results.visible {
|
||
display: block;
|
||
}
|
||
|
||
.lookup-post {
|
||
padding: 14px 22px;
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
display: flex;
|
||
gap: 14px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.lookup-post:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.lookup-post-image {
|
||
width: 76px;
|
||
height: 76px;
|
||
border-radius: var(--r-sm);
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
background: var(--card);
|
||
cursor: pointer;
|
||
border: 1px solid var(--rule);
|
||
}
|
||
|
||
.lookup-post-image:hover {
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.lookup-post-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.lookup-post-title {
|
||
font-size: 14px;
|
||
color: var(--ink);
|
||
font-weight: 500;
|
||
margin-bottom: 5px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.lookup-post-title a {
|
||
color: var(--terracotta-deep) !important;
|
||
text-decoration: none !important;
|
||
}
|
||
|
||
.lookup-post-title a:hover {
|
||
text-decoration: underline !important;
|
||
}
|
||
|
||
.lookup-post-meta {
|
||
font-size: 12px;
|
||
color: var(--ink-faint);
|
||
margin-bottom: 6px;
|
||
font-family: var(--f-mono);
|
||
}
|
||
|
||
.lookup-post-status {
|
||
font-size: 10px;
|
||
padding: 3px 9px;
|
||
border-radius: var(--r-pill);
|
||
display: inline-block;
|
||
font-weight: 500;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.lookup-post-status.sent {
|
||
background: var(--sage-soft);
|
||
color: var(--sage-deep);
|
||
}
|
||
|
||
.lookup-post-status.error {
|
||
background: var(--clay-soft);
|
||
color: var(--clay);
|
||
}
|
||
|
||
.lookup-post-status.pending {
|
||
background: var(--card-2);
|
||
color: var(--ink-soft);
|
||
}
|
||
|
||
.lookup-close {
|
||
background: none;
|
||
border: none;
|
||
color: var(--ink-faint);
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
padding: 4px 8px;
|
||
transition: color .2s;
|
||
}
|
||
|
||
.lookup-close:hover {
|
||
color: var(--ink);
|
||
}
|
||
|
||
.lookup-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 22px 4px;
|
||
font-size: 12px;
|
||
color: var(--ink-soft);
|
||
font-family: var(--f-display);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* ───── Messages list ───── */
|
||
.message-item {
|
||
padding: 18px 22px;
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
}
|
||
|
||
.message-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.message-recipient {
|
||
font-weight: 600;
|
||
color: var(--terracotta-deep);
|
||
font-size: 13px;
|
||
margin-bottom: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.message-text {
|
||
font-size: 14px;
|
||
color: var(--ink);
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.message-meta {
|
||
font-size: 11px;
|
||
color: var(--ink-faint);
|
||
margin-top: 10px;
|
||
display: flex;
|
||
gap: 12px;
|
||
font-family: var(--f-mono);
|
||
}
|
||
|
||
.response-badge {
|
||
background: var(--lilac-soft);
|
||
color: #6f6395;
|
||
padding: 3px 10px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.message-opener {
|
||
margin-top: 10px;
|
||
padding: 10px 12px;
|
||
background: var(--card-2);
|
||
border-left: 2px solid var(--terracotta);
|
||
border-radius: var(--r-sm);
|
||
}
|
||
|
||
.message-section-label {
|
||
display: block;
|
||
font-family: var(--f-mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-faint);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.message-image-analysis {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: var(--ink-soft);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.message-image-analysis summary {
|
||
cursor: pointer;
|
||
list-style: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 10px;
|
||
background: var(--card-2);
|
||
border: 1px solid var(--rule-soft);
|
||
border-radius: var(--r-pill);
|
||
font-family: var(--f-mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-soft);
|
||
user-select: none;
|
||
transition: background 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
|
||
.message-image-analysis summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
|
||
.message-image-analysis summary::before {
|
||
content: '🖼️';
|
||
font-size: 11px;
|
||
}
|
||
|
||
.message-image-analysis summary:hover {
|
||
background: var(--card);
|
||
border-color: var(--rule);
|
||
}
|
||
|
||
.message-image-analysis[open] summary::after {
|
||
content: '▴';
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.message-image-analysis:not([open]) summary::after {
|
||
content: '▾';
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.message-image-analysis-body {
|
||
margin-top: 8px;
|
||
padding: 10px 12px;
|
||
background: var(--card-2);
|
||
border-radius: var(--r-sm);
|
||
border: 1px dashed var(--rule-soft);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* ───── Config ───── */
|
||
.config-section {
|
||
margin-top: 28px;
|
||
}
|
||
|
||
.config-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.config-item {
|
||
background: var(--card);
|
||
border-radius: var(--r-md);
|
||
padding: 16px 18px;
|
||
border: 1px solid var(--rule);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.config-item h4 {
|
||
font-family: var(--f-body);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--ink-faint);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.config-item .value {
|
||
color: var(--ink);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.tag {
|
||
display: inline-block;
|
||
background: var(--terracotta-soft);
|
||
color: var(--terracotta-deep);
|
||
padding: 5px 12px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
margin: 3px 3px 3px 0;
|
||
border: 1px solid rgba(201, 123, 94, .2);
|
||
}
|
||
|
||
.tag.tag-chat {
|
||
background: var(--sage-soft);
|
||
color: var(--sage-deep);
|
||
border-color: rgba(120, 150, 110, .25);
|
||
}
|
||
|
||
.subreddit-editor {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.subreddit-editor-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(140px, 1.5fr) 90px 1fr 70px 32px;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 6px 8px;
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-sm);
|
||
}
|
||
|
||
.subreddit-editor-row.new {
|
||
border-style: dashed;
|
||
border-color: var(--terracotta-soft);
|
||
}
|
||
|
||
.subreddit-editor-row input,
|
||
.subreddit-editor-row select {
|
||
width: 100%;
|
||
padding: 5px 8px;
|
||
font-size: 12px;
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-sm);
|
||
font-family: inherit;
|
||
}
|
||
|
||
.subreddit-editor-row input[readonly] {
|
||
background: transparent;
|
||
border-color: transparent;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
padding-left: 4px;
|
||
}
|
||
|
||
.subreddit-editor-row .remove-btn {
|
||
background: transparent;
|
||
color: var(--ink-faint);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-sm);
|
||
cursor: pointer;
|
||
padding: 4px;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.subreddit-editor-row .remove-btn:hover {
|
||
background: var(--terracotta-soft);
|
||
color: var(--terracotta-deep);
|
||
border-color: rgba(201, 123, 94, .3);
|
||
}
|
||
|
||
.subreddit-editor-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.subreddit-editor-actions button {
|
||
padding: 7px 14px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
border-radius: var(--r-sm);
|
||
cursor: pointer;
|
||
border: 1px solid var(--rule);
|
||
background: var(--card);
|
||
color: var(--ink);
|
||
font-family: inherit;
|
||
}
|
||
|
||
.subreddit-editor-actions button.primary {
|
||
background: var(--terracotta-deep);
|
||
color: white;
|
||
border-color: var(--terracotta-deep);
|
||
}
|
||
|
||
.subreddit-editor-actions button.primary:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.subreddit-editor-status {
|
||
font-size: 12px;
|
||
color: var(--ink-faint);
|
||
margin-left: auto;
|
||
}
|
||
|
||
.subreddit-editor-status.error {
|
||
color: var(--terracotta-deep);
|
||
}
|
||
|
||
.subreddit-editor-status.success {
|
||
color: var(--sage-deep);
|
||
}
|
||
|
||
.empty-state {
|
||
padding: 48px 22px;
|
||
text-align: center;
|
||
color: var(--ink-faint);
|
||
font-style: italic;
|
||
font-family: var(--f-display);
|
||
font-weight: 400;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* ───── Chats ───── */
|
||
.chat-item {
|
||
padding: 14px 22px;
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
display: flex;
|
||
gap: 14px;
|
||
align-items: flex-start;
|
||
transition: background .2s;
|
||
}
|
||
|
||
.chat-item:hover {
|
||
background: var(--card-2);
|
||
}
|
||
|
||
.chat-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.chat-item.unread {
|
||
background: var(--lilac-soft);
|
||
}
|
||
|
||
.chat-item.unread:hover {
|
||
background: #d8d0e8;
|
||
}
|
||
|
||
.chat-avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: var(--r-pill);
|
||
background:
|
||
radial-gradient(circle at 30% 30%, #f3d9cd 0%, #c97b5e 65%, #a85f43 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #fff8ef;
|
||
flex-shrink: 0;
|
||
font-family: var(--f-display);
|
||
box-shadow: inset 0 -2px 4px rgba(0, 0, 0, .15);
|
||
}
|
||
|
||
.chat-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.chat-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 4px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chat-username {
|
||
font-weight: 600;
|
||
color: var(--terracotta-deep);
|
||
font-size: 13px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.chat-username a,
|
||
.chat-username {
|
||
color: var(--terracotta-deep) !important;
|
||
}
|
||
|
||
.chat-time {
|
||
font-size: 11px;
|
||
color: var(--ink-faint);
|
||
font-family: var(--f-mono);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.chat-snippet {
|
||
font-size: 13px;
|
||
color: var(--ink-soft);
|
||
line-height: 1.45;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.chat-direction {
|
||
font-size: 10px;
|
||
color: var(--ink-faint);
|
||
margin-top: 5px;
|
||
font-family: var(--f-mono);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.chat-direction.from-us {
|
||
color: var(--sage-deep);
|
||
}
|
||
|
||
.chat-direction.from-them {
|
||
color: #6f6395;
|
||
}
|
||
|
||
.unread-badge {
|
||
background: var(--lilac);
|
||
color: #fff;
|
||
padding: 2px 9px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
/* ───── Scrollbar ───── */
|
||
::-webkit-scrollbar {
|
||
width: 10px;
|
||
height: 10px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--rule);
|
||
border-radius: var(--r-pill);
|
||
border: 2px solid var(--card);
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: var(--ink-faint);
|
||
}
|
||
|
||
/* ───── Modal ───── */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(61, 46, 38, .45);
|
||
z-index: 1000;
|
||
justify-content: center;
|
||
align-items: center;
|
||
backdrop-filter: blur(8px);
|
||
-webkit-backdrop-filter: blur(8px);
|
||
padding: 24px;
|
||
}
|
||
|
||
.modal-overlay.active {
|
||
display: flex;
|
||
animation: fadeIn .25s ease-out;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
.modal-content {
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-xl);
|
||
width: 100%;
|
||
max-width: 720px;
|
||
max-height: 85vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: var(--shadow-lg);
|
||
animation: slideUp .3s cubic-bezier(.2, .8, .2, 1);
|
||
}
|
||
|
||
@keyframes slideUp {
|
||
from { transform: translateY(20px); opacity: 0; }
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
|
||
.modal-header {
|
||
padding: 22px 26px;
|
||
background: linear-gradient(180deg, var(--card-2) 0%, transparent 100%);
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.modal-header h2 {
|
||
font-family: var(--f-display);
|
||
font-weight: 500;
|
||
font-size: 20px;
|
||
color: var(--ink);
|
||
letter-spacing: -0.015em;
|
||
}
|
||
|
||
.modal-header .close-btn {
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
color: var(--ink-soft);
|
||
font-size: 18px;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: var(--r-pill);
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
line-height: 1;
|
||
}
|
||
|
||
.modal-header .close-btn:hover {
|
||
background: var(--card-2);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 22px 26px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.conversation-meta {
|
||
display: flex;
|
||
gap: 18px;
|
||
margin-bottom: 22px;
|
||
padding-bottom: 18px;
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.conversation-meta .meta-item {
|
||
font-size: 12px;
|
||
color: var(--ink-faint);
|
||
font-family: var(--f-body);
|
||
}
|
||
|
||
.conversation-meta .meta-item span {
|
||
color: var(--ink);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.message-bubble {
|
||
padding: 12px 16px;
|
||
border-radius: var(--r-lg);
|
||
margin-bottom: 10px;
|
||
max-width: 78%;
|
||
line-height: 1.5;
|
||
font-size: 14px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.message-bubble.sent {
|
||
background: var(--terracotta);
|
||
color: #fff8ef;
|
||
margin-left: auto;
|
||
border-bottom-right-radius: 6px;
|
||
}
|
||
|
||
.message-bubble.received {
|
||
background: var(--card-2);
|
||
color: var(--ink);
|
||
border-bottom-left-radius: 6px;
|
||
border: 1px solid var(--rule);
|
||
}
|
||
|
||
.message-bubble .sender {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.message-bubble.sent .sender {
|
||
color: rgba(255, 248, 239, .85);
|
||
}
|
||
|
||
.message-bubble.received .sender {
|
||
color: var(--terracotta-deep);
|
||
}
|
||
|
||
.message-bubble .media {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.message-bubble .media img {
|
||
max-width: 240px;
|
||
max-height: 240px;
|
||
border-radius: 8px;
|
||
cursor: zoom-in;
|
||
display: block;
|
||
object-fit: cover;
|
||
background: rgba(0, 0, 0, .05);
|
||
}
|
||
|
||
.message-bubble .text:empty {
|
||
display: none;
|
||
}
|
||
|
||
.no-messages {
|
||
text-align: center;
|
||
color: var(--ink-faint);
|
||
padding: 40px;
|
||
font-style: italic;
|
||
font-family: var(--f-display);
|
||
}
|
||
|
||
/* ───── Conversation cards ───── */
|
||
.conversation-card {
|
||
padding: 14px 22px;
|
||
border-bottom: 1px solid var(--rule-soft);
|
||
display: flex;
|
||
gap: 14px;
|
||
align-items: flex-start;
|
||
cursor: pointer;
|
||
transition: background .2s;
|
||
position: relative;
|
||
}
|
||
|
||
.conversation-card:hover {
|
||
background: var(--card-2);
|
||
}
|
||
|
||
.conversation-card:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.conversation-card.has-reply {
|
||
border-left: 3px solid var(--sage);
|
||
}
|
||
|
||
.message-count-badge {
|
||
background: var(--card-2);
|
||
color: var(--ink-soft);
|
||
padding: 2px 9px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
margin-left: 6px;
|
||
border: 1px solid var(--rule);
|
||
font-family: var(--f-mono);
|
||
}
|
||
|
||
.reply-indicator {
|
||
background: var(--sage-soft);
|
||
color: var(--sage-deep);
|
||
padding: 3px 10px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 10px;
|
||
margin-left: 6px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.pending-indicator {
|
||
background: var(--honey-soft);
|
||
color: #8a6432;
|
||
padding: 3px 10px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 10px;
|
||
margin-left: 6px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
animation: pulse-pending 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse-pending {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.55; }
|
||
}
|
||
|
||
.upsell-indicator {
|
||
background: var(--clay-soft);
|
||
color: var(--clay);
|
||
padding: 3px 10px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 10px;
|
||
margin-left: 6px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.conversation-card.has-pending {
|
||
border-left: 3px solid var(--honey);
|
||
background: linear-gradient(90deg, rgba(212, 165, 116, .08) 0%, transparent 50%);
|
||
}
|
||
|
||
.conversation-card.has-upsell {
|
||
border-left: 3px solid var(--clay);
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.pending-reply-box {
|
||
margin-top: 18px;
|
||
padding: 16px 18px;
|
||
background: var(--honey-soft);
|
||
border: 1px solid rgba(212, 165, 116, .35);
|
||
border-radius: var(--r-md);
|
||
}
|
||
|
||
.pending-reply-label {
|
||
font-size: 10px;
|
||
color: #8a6432;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.pending-reply-text {
|
||
color: var(--ink);
|
||
font-size: 14px;
|
||
line-height: 1.55;
|
||
white-space: pre-wrap;
|
||
margin-bottom: 14px;
|
||
padding: 12px 14px;
|
||
background: var(--card);
|
||
border-radius: var(--r-sm);
|
||
border: 1px solid var(--rule);
|
||
}
|
||
|
||
.pending-reply-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.approve-btn {
|
||
padding: 9px 18px;
|
||
background: var(--sage);
|
||
color: #fff;
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
box-shadow: 0 4px 10px -4px rgba(111, 139, 95, .55);
|
||
}
|
||
|
||
.approve-btn:hover {
|
||
background: var(--sage-deep);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.approve-btn:disabled,
|
||
.reject-btn:disabled,
|
||
.send-btn:disabled,
|
||
.suggest-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
.reject-btn {
|
||
padding: 9px 18px;
|
||
background: var(--clay);
|
||
color: #fff;
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
box-shadow: 0 4px 10px -4px rgba(184, 92, 92, .55);
|
||
}
|
||
|
||
.reject-btn:hover {
|
||
background: #9b4949;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.dismiss-btn {
|
||
padding: 8px 16px;
|
||
background: var(--card);
|
||
color: var(--ink-soft);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
}
|
||
|
||
.dismiss-btn:hover {
|
||
background: var(--card-2);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.upsell-box {
|
||
margin-top: 18px;
|
||
padding: 16px 18px;
|
||
background: var(--clay-soft);
|
||
border: 1px solid rgba(184, 92, 92, .3);
|
||
border-radius: var(--r-md);
|
||
}
|
||
|
||
.upsell-label {
|
||
font-size: 10px;
|
||
color: var(--clay);
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.upsell-text {
|
||
color: var(--ink);
|
||
font-size: 13px;
|
||
margin-bottom: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.pending-action-status {
|
||
font-size: 12px;
|
||
margin-left: 4px;
|
||
font-family: var(--f-mono);
|
||
}
|
||
|
||
/* ───── Media gallery ───── */
|
||
.media-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||
gap: 14px;
|
||
padding: 18px;
|
||
max-height: none;
|
||
}
|
||
|
||
.media-card {
|
||
background: var(--card-2);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-md);
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 220px;
|
||
text-decoration: none;
|
||
color: inherit;
|
||
transition: transform .2s ease, box-shadow .2s ease;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.media-card:hover {
|
||
transform: translateY(-3px);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
|
||
.media-card img {
|
||
width: 100%;
|
||
height: 150px;
|
||
object-fit: cover;
|
||
display: block;
|
||
background: var(--card-2);
|
||
filter: blur(18px);
|
||
transition: filter .2s ease;
|
||
}
|
||
|
||
.media-card:hover img,
|
||
.media-card.revealed img {
|
||
filter: none;
|
||
}
|
||
|
||
.media-grid.revealed-all .media-card img {
|
||
filter: none;
|
||
}
|
||
|
||
.unblur-all-btn {
|
||
padding: 7px 13px;
|
||
border-radius: var(--r-pill);
|
||
background: var(--card);
|
||
color: var(--ink-soft);
|
||
border-color: var(--rule);
|
||
font-size: 12px;
|
||
font-family: var(--f-body);
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: transform .15s ease, background .2s ease, box-shadow .2s ease, color .2s ease;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.unblur-all-btn:hover {
|
||
background: var(--card-2);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.unblur-all-btn.active {
|
||
background: var(--terracotta-soft);
|
||
color: var(--terracotta-deep);
|
||
border-color: rgba(201, 123, 94, .35);
|
||
}
|
||
|
||
.media-meta {
|
||
padding: 11px 13px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
font-size: 11px;
|
||
color: var(--ink-faint);
|
||
font-family: var(--f-mono);
|
||
}
|
||
|
||
.media-meta .media-user {
|
||
color: var(--terracotta-deep);
|
||
font-weight: 600;
|
||
font-family: var(--f-body);
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ───── Realtime log ───── */
|
||
.realtime-log {
|
||
font-family: var(--f-mono);
|
||
font-size: 12px;
|
||
background:
|
||
linear-gradient(180deg, var(--mocha) 0%, var(--mocha-2) 100%);
|
||
border: 1px solid rgba(255, 248, 239, .08);
|
||
border-radius: var(--r-md);
|
||
height: 340px;
|
||
overflow-y: auto;
|
||
padding: 14px;
|
||
margin: 16px;
|
||
box-shadow: inset 0 2px 8px rgba(0, 0, 0, .3);
|
||
position: relative;
|
||
}
|
||
|
||
.realtime-log::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
border-radius: var(--r-md);
|
||
background: linear-gradient(180deg, rgba(255, 248, 239, .04) 0%, transparent 8%);
|
||
}
|
||
|
||
.realtime-log::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 248, 239, .12);
|
||
border: 2px solid transparent;
|
||
background-clip: padding-box;
|
||
}
|
||
|
||
.realtime-log .empty-state {
|
||
color: var(--cream-on-mocha-soft);
|
||
}
|
||
|
||
.log-entry {
|
||
padding: 4px 8px;
|
||
margin-bottom: 2px;
|
||
border-radius: var(--r-xs);
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.log-entry .log-time {
|
||
color: rgba(255, 248, 239, .35);
|
||
flex-shrink: 0;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.log-entry .log-level {
|
||
padding: 1px 7px;
|
||
border-radius: var(--r-xs);
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.log-entry.info .log-level {
|
||
background: rgba(127, 166, 184, .25);
|
||
color: #b4d2de;
|
||
}
|
||
|
||
.log-entry.warn .log-level {
|
||
background: rgba(212, 165, 116, .3);
|
||
color: #f0c590;
|
||
}
|
||
|
||
.log-entry.error .log-level {
|
||
background: rgba(184, 92, 92, .35);
|
||
color: #e8a3a3;
|
||
}
|
||
|
||
.log-entry.debug .log-level {
|
||
background: rgba(255, 248, 239, .15);
|
||
color: var(--cream-on-mocha-soft);
|
||
}
|
||
|
||
.log-entry .log-message {
|
||
color: var(--cream-on-mocha);
|
||
word-break: break-word;
|
||
flex: 1;
|
||
}
|
||
|
||
.log-entry.error .log-message {
|
||
color: #e8a3a3;
|
||
}
|
||
|
||
.log-entry.warn .log-message {
|
||
color: #f0c590;
|
||
}
|
||
|
||
.log-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-pill);
|
||
font-size: 11px;
|
||
color: var(--ink-soft);
|
||
font-family: var(--f-mono);
|
||
margin-left: auto;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.log-status-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: var(--r-pill);
|
||
background: var(--clay);
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
.log-status-dot.connected {
|
||
background: var(--sage-deep);
|
||
animation: none;
|
||
box-shadow: 0 0 0 3px var(--sage-soft);
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
|
||
/* ───── Settings form ───── */
|
||
.settings-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
}
|
||
|
||
.settings-form h3 {
|
||
font-family: var(--f-display);
|
||
font-weight: 500;
|
||
font-style: italic;
|
||
font-size: 14px !important;
|
||
color: var(--ink-soft) !important;
|
||
margin: 12px 0 4px !important;
|
||
letter-spacing: -0.005em;
|
||
}
|
||
|
||
.form-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.form-group label {
|
||
font-size: 11px;
|
||
color: var(--ink-faint);
|
||
text-transform: uppercase;
|
||
font-weight: 500;
|
||
letter-spacing: 0.1em;
|
||
}
|
||
|
||
.form-group input[type="number"],
|
||
.form-group input[type="text"] {
|
||
background: var(--card-2);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-sm);
|
||
padding: 11px 14px;
|
||
color: var(--ink);
|
||
font-size: 14px;
|
||
font-family: var(--f-body);
|
||
transition: all .2s ease;
|
||
}
|
||
|
||
.form-group input:focus {
|
||
outline: none;
|
||
border-color: var(--terracotta);
|
||
background: var(--card);
|
||
box-shadow: 0 0 0 3px var(--terracotta-soft);
|
||
}
|
||
|
||
.form-group .hint {
|
||
font-size: 11px;
|
||
color: var(--ink-faint);
|
||
font-style: italic;
|
||
font-family: var(--f-display);
|
||
}
|
||
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
|
||
.toggle-group {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 16px;
|
||
background: var(--card-2);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-sm);
|
||
}
|
||
|
||
.toggle-group label {
|
||
font-size: 13px;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.toggle {
|
||
position: relative;
|
||
width: 46px;
|
||
height: 26px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toggle input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.toggle-slider {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
inset: 0;
|
||
background: var(--rule);
|
||
border-radius: var(--r-pill);
|
||
transition: all .3s ease;
|
||
}
|
||
|
||
.toggle-slider:before {
|
||
position: absolute;
|
||
content: "";
|
||
height: 20px;
|
||
width: 20px;
|
||
left: 3px;
|
||
bottom: 3px;
|
||
background: var(--card);
|
||
border-radius: 50%;
|
||
transition: all .3s cubic-bezier(.2, .8, .2, 1);
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, .15);
|
||
}
|
||
|
||
.toggle input:checked + .toggle-slider {
|
||
background: var(--sage);
|
||
}
|
||
|
||
.toggle input:checked + .toggle-slider:before {
|
||
transform: translateX(20px);
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 16px 26px;
|
||
background: var(--card-2);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
border-top: 1px solid var(--rule-soft);
|
||
}
|
||
|
||
/* Suggested reply */
|
||
.suggested-reply {
|
||
margin-top: 18px;
|
||
padding: 14px 16px;
|
||
background: var(--lilac-soft);
|
||
border: 1px solid rgba(154, 143, 192, .3);
|
||
border-radius: var(--r-md);
|
||
}
|
||
|
||
.suggested-reply-label {
|
||
font-size: 10px;
|
||
color: #6f6395;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.suggested-reply-text {
|
||
color: var(--ink);
|
||
font-size: 14px;
|
||
line-height: 1.55;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.suggest-btn {
|
||
margin-top: 12px;
|
||
padding: 9px 18px;
|
||
background: var(--lilac);
|
||
color: #fff;
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
box-shadow: 0 4px 10px -4px rgba(154, 143, 192, .55);
|
||
}
|
||
|
||
.suggest-btn:hover {
|
||
background: #877ab0;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.reply-editor {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.reply-editor textarea {
|
||
width: 100%;
|
||
min-height: 90px;
|
||
padding: 12px;
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-sm);
|
||
color: var(--ink);
|
||
font-size: 14px;
|
||
font-family: var(--f-body);
|
||
resize: vertical;
|
||
line-height: 1.55;
|
||
transition: border-color .2s, box-shadow .2s;
|
||
}
|
||
|
||
.reply-editor textarea:focus {
|
||
outline: none;
|
||
border-color: var(--terracotta);
|
||
box-shadow: 0 0 0 3px var(--terracotta-soft);
|
||
}
|
||
|
||
.reply-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.send-btn {
|
||
padding: 9px 18px;
|
||
background: var(--sage);
|
||
color: #fff;
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
box-shadow: 0 4px 10px -4px rgba(111, 139, 95, .55);
|
||
}
|
||
|
||
.send-btn:hover {
|
||
background: var(--sage-deep);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.send-status {
|
||
font-size: 12px;
|
||
margin-left: 4px;
|
||
font-family: var(--f-mono);
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--ink);
|
||
color: var(--card);
|
||
border: 1px solid var(--ink);
|
||
padding: 10px 22px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: #2a1f1a;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: var(--card);
|
||
color: var(--ink-soft);
|
||
border: 1px solid var(--rule);
|
||
padding: 10px 22px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: var(--card-2);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.clear-btn {
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
color: var(--ink-soft);
|
||
padding: 5px 12px;
|
||
border-radius: var(--r-pill);
|
||
font-size: 11px;
|
||
font-family: var(--f-body);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.clear-btn:hover {
|
||
background: var(--card-2);
|
||
color: var(--ink);
|
||
}
|
||
|
||
/* ───── Timers ───── */
|
||
.timer-card {
|
||
background: var(--card);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-md);
|
||
padding: 18px 20px;
|
||
box-shadow: var(--shadow-sm);
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: transform .2s, box-shadow .2s;
|
||
}
|
||
|
||
.timer-card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
|
||
.timer-card::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 80px;
|
||
height: 80px;
|
||
background: radial-gradient(circle, var(--sage-soft) 0%, transparent 70%);
|
||
opacity: 0.4;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.timer-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
margin-bottom: 14px;
|
||
gap: 12px;
|
||
position: relative;
|
||
}
|
||
|
||
.timer-title {
|
||
font-family: var(--f-body);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--ink-faint);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.timer-value {
|
||
font-family: var(--f-mono);
|
||
font-size: 22px;
|
||
font-weight: 500;
|
||
color: var(--ink);
|
||
letter-spacing: -0.01em;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.timer-progress-bg {
|
||
height: 6px;
|
||
background: var(--card-2);
|
||
border-radius: var(--r-pill);
|
||
overflow: hidden;
|
||
margin-top: 10px;
|
||
border: 1px solid var(--rule-soft);
|
||
}
|
||
|
||
.timer-progress-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--sage), var(--sage-deep));
|
||
width: 0%;
|
||
transition: width 1s linear;
|
||
border-radius: var(--r-pill);
|
||
}
|
||
|
||
.timer-progress-fill.warning {
|
||
background: linear-gradient(90deg, var(--honey), #c8943a);
|
||
}
|
||
|
||
.timer-progress-fill.danger {
|
||
background: linear-gradient(90deg, var(--clay), #8e3e3e);
|
||
}
|
||
|
||
.timer-status {
|
||
font-size: 11px;
|
||
color: var(--ink-faint);
|
||
margin-top: 8px;
|
||
font-family: var(--f-display);
|
||
font-style: italic;
|
||
}
|
||
|
||
.timer-icon {
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* ───── Footer ───── */
|
||
.page-footer {
|
||
margin-top: 60px;
|
||
padding-top: 28px;
|
||
border-top: 1px solid var(--rule);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-size: 11px;
|
||
color: var(--ink-faint);
|
||
font-family: var(--f-display);
|
||
font-style: italic;
|
||
}
|
||
|
||
.page-footer .mono {
|
||
font-family: var(--f-mono);
|
||
font-style: normal;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
/* ───── Initial reveal ───── */
|
||
.reveal {
|
||
animation: rise .7s cubic-bezier(.2, .8, .2, 1) both;
|
||
}
|
||
|
||
@keyframes rise {
|
||
from { opacity: 0; transform: translateY(14px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.reveal-1 { animation-delay: .05s; }
|
||
.reveal-2 { animation-delay: .15s; }
|
||
.reveal-3 { animation-delay: .25s; }
|
||
.reveal-4 { animation-delay: .35s; }
|
||
.reveal-5 { animation-delay: .45s; }
|
||
.reveal-6 { animation-delay: .55s; }
|
||
.reveal-7 { animation-delay: .65s; }
|
||
.reveal-8 { animation-delay: .75s; }
|
||
|
||
@media (max-width: 720px) {
|
||
.container {
|
||
padding: 16px 16px 60px;
|
||
}
|
||
header.toolbar {
|
||
flex-wrap: wrap;
|
||
border-radius: var(--r-lg);
|
||
padding: 12px 14px;
|
||
gap: 10px;
|
||
}
|
||
.header-controls {
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
.stats-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.stat-card.highlight .value {
|
||
font-size: 48px;
|
||
}
|
||
.form-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* ───── Page routing ───── */
|
||
.page-nav {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-left: auto;
|
||
margin-right: 16px;
|
||
}
|
||
.page-nav-link {
|
||
padding: 6px 12px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
color: var(--text-muted, #888);
|
||
text-decoration: none;
|
||
border: 1px solid transparent;
|
||
transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
|
||
}
|
||
.page-nav-link:hover {
|
||
color: var(--text, #eee);
|
||
background: rgba(255, 255, 255, 0.04);
|
||
}
|
||
.page-nav-link.is-active {
|
||
color: var(--text, #eee);
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border-color: rgba(255, 255, 255, 0.1);
|
||
}
|
||
body[data-page="dashboard"] [data-page="gallery"],
|
||
body[data-page="gallery"] [data-page="dashboard"] {
|
||
display: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<header class="toolbar reveal reveal-1">
|
||
<div class="brand">
|
||
<div class="brand-mark">r</div>
|
||
<div class="brand-text">
|
||
<div class="brand-title">Reddit Bot</div>
|
||
<div class="brand-sub">a console for warm conversations</div>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar-status">
|
||
<span class="status-badge status-running" id="running-badge" style="display: none;">Running</span>
|
||
<span class="status-badge status-stopped" id="stopped-badge">Stopped</span>
|
||
<span class="status-badge status-dry-run" id="dryrun-badge" style="display: none;">Dry Run</span>
|
||
</div>
|
||
<nav class="page-nav" id="page-nav">
|
||
<a href="#dashboard" class="page-nav-link" data-nav="dashboard">Dashboard</a>
|
||
<a href="#gallery" class="page-nav-link" data-nav="gallery">Gallery</a>
|
||
</nav>
|
||
<div class="header-controls">
|
||
<button class="settings-btn" id="login-btn" onclick="openLoginModal()" style="display: none;">🔑 Log in to Reddit</button>
|
||
<button class="settings-btn" onclick="openSettingsModal()">⚙ Settings</button>
|
||
<button class="control-btn start" id="control-btn" onclick="toggleBot()">Start Bot</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="container">
|
||
|
||
<div id="cooldown-banner" style="display:none; margin:1rem 0; padding:0.85rem 1rem; border-radius:8px; background:#fff4e5; border:1px solid #f5b86a; color:#7a4a00; font-size:0.92rem; line-height:1.4;">
|
||
<strong>⏸️ Chat throttle active.</strong>
|
||
<span id="cooldown-reason"></span>
|
||
<span style="opacity:0.8;">Sends paused for <span id="cooldown-remaining">—</span>.</span>
|
||
</div>
|
||
|
||
<!-- 01 — At a glance -->
|
||
<section class="section reveal reveal-2" data-page="dashboard">
|
||
<div class="section-eyebrow">
|
||
<span class="num">01</span>
|
||
<span class="title">At a glance</span>
|
||
<span class="rule"></span>
|
||
</div>
|
||
<div class="stats-grid">
|
||
<div class="stat-card highlight">
|
||
<h3>Messages sent</h3>
|
||
<div class="value" id="stat-sent">0</div>
|
||
<div class="subtext"><span id="stat-hour">0</span> / <span id="stat-max-hour">8</span> this hour</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3>Responses</h3>
|
||
<div class="value" id="stat-responses">0</div>
|
||
<div class="subtext"><span id="stat-rate">0</span>% response rate</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3>Posts processed</h3>
|
||
<div class="value" id="stat-processed">0</div>
|
||
<div class="subtext">Total analyzed</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3>Success examples</h3>
|
||
<div class="value" id="stat-examples">0</div>
|
||
<div class="subtext">Learning from responses</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 02 — Operating window -->
|
||
<section class="section reveal reveal-3" data-page="dashboard">
|
||
<div class="section-eyebrow">
|
||
<span class="num">02</span>
|
||
<span class="title">Operating window</span>
|
||
<span class="rule"></span>
|
||
</div>
|
||
<div class="panels">
|
||
<div class="panel" style="grid-column: span 2;">
|
||
<div class="panel-header">Active timers</div>
|
||
<div class="panel-content" style="padding: 18px; max-height: none;">
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px;">
|
||
|
||
<div class="timer-card">
|
||
<div class="timer-header">
|
||
<div class="timer-title">
|
||
<span class="timer-icon">🚀</span> Next message
|
||
</div>
|
||
<div class="timer-value" id="timer-next-msg">Ready</div>
|
||
</div>
|
||
<div class="timer-progress-bg">
|
||
<div class="timer-progress-fill" id="progress-next-msg"></div>
|
||
</div>
|
||
<div class="timer-status" id="status-next-msg">Waiting for new contents…</div>
|
||
</div>
|
||
|
||
<div class="timer-card">
|
||
<div class="timer-header">
|
||
<div class="timer-title">
|
||
<span class="timer-icon">⏰</span> Hourly limit reset
|
||
</div>
|
||
<div class="timer-value" id="timer-hourly">60:00</div>
|
||
</div>
|
||
<div class="timer-progress-bg">
|
||
<div class="timer-progress-fill warning" id="progress-hourly" style="width: 100%"></div>
|
||
</div>
|
||
<div class="timer-status" id="status-hourly">
|
||
<span id="msgs-remaining">0</span> messages remaining this hour
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 03 — Activity & messages -->
|
||
<section class="section reveal reveal-4" data-page="dashboard">
|
||
<div class="section-eyebrow">
|
||
<span class="num">03</span>
|
||
<span class="title">Activity & messages</span>
|
||
<span class="rule"></span>
|
||
</div>
|
||
<div class="panels">
|
||
<div class="panel">
|
||
<div class="panel-header">Recent activity</div>
|
||
<div class="user-search">
|
||
<input type="text" id="user-lookup-input" placeholder="Look up user (e.g. username)"
|
||
onkeydown="if(event.key==='Enter')lookupUser()">
|
||
<button onclick="lookupUser()">Search</button>
|
||
</div>
|
||
<div class="user-lookup-results" id="user-lookup-results"></div>
|
||
<div class="panel-content" id="activity-list">
|
||
<div class="empty-state">No activity yet</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<div class="panel-header">Recent messages</div>
|
||
<div class="panel-content" id="messages-list">
|
||
<div class="empty-state">No messages sent yet</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 04 — Live chats -->
|
||
<section class="section reveal reveal-5" data-page="dashboard">
|
||
<div class="section-eyebrow">
|
||
<span class="num">04</span>
|
||
<span class="title">Live chats & conversations</span>
|
||
<span class="rule"></span>
|
||
</div>
|
||
|
||
<div class="panels">
|
||
<div class="panel" style="grid-column: span 2;">
|
||
<div class="panel-header-row" style="flex-wrap: wrap; row-gap: 6px;">
|
||
<span class="panel-title">Active chats · last 48 hours</span>
|
||
<span id="chat-sync-status-main" class="muted" style="font-size: 12px; margin-left: 12px;"></span>
|
||
<div id="sync-progress-bar-main" style="display: none; flex-basis: 100%; height: 4px; background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden; margin-top: 4px;">
|
||
<div id="sync-progress-fill-main" style="height: 100%; width: 0%; background: var(--accent, #6cf); transition: width 200ms ease;"></div>
|
||
</div>
|
||
<span style="flex: 1;"></span>
|
||
<button class="refresh-btn" onclick="fetchFromReddit()" id="fetch-reddit-btn"
|
||
style="background: var(--terracotta); color: #fff8ef; border-color: var(--terracotta-deep);">🌐 Fetch from Reddit</button>
|
||
<button class="refresh-btn" onclick="fetchChats()" id="refresh-chats-btn">🔄 Refresh cache</button>
|
||
</div>
|
||
<div class="panel-content" id="chats-list" style="max-height: 500px;">
|
||
<div class="empty-state">Loading chats…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panels" style="margin-top: 18px;">
|
||
<div class="panel" style="grid-column: span 2;">
|
||
<div class="panel-header-row">
|
||
<span class="panel-title">Cached conversations · browsable archive</span>
|
||
<select id="conversations-filter" onchange="renderConversations()" style="margin-left: auto; margin-right: 8px; background: rgba(0,0,0,0.3); color: inherit; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 4px 8px; font: inherit;">
|
||
<option value="all">All conversations</option>
|
||
<option value="replied">Got reply</option>
|
||
<option value="no-reply">No reply yet</option>
|
||
</select>
|
||
<button class="refresh-btn" onclick="fetchConversations()" id="refresh-conversations-btn">🔄 Refresh</button>
|
||
</div>
|
||
<div class="panel-content" id="conversations-list" style="max-height: 500px;">
|
||
<div class="empty-state">Loading cached conversations…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 05 — Media gallery -->
|
||
<section class="section reveal reveal-6" data-page="gallery">
|
||
<div class="section-eyebrow">
|
||
<span class="num">05</span>
|
||
<span class="title">Media gallery</span>
|
||
<span class="rule"></span>
|
||
</div>
|
||
<div class="panels">
|
||
<div class="panel" style="grid-column: span 2;">
|
||
<div class="panel-header-row" style="flex-wrap: wrap; row-gap: 6px;">
|
||
<span class="panel-title">Collected media</span>
|
||
<span id="chat-sync-status" class="muted" style="font-size: 12px; margin-left: 12px;">Sync status…</span>
|
||
<div id="sync-progress-bar" style="display: none; flex-basis: 100%; height: 4px; background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden; margin-top: 4px;">
|
||
<div id="sync-progress-fill" style="height: 100%; width: 0%; background: var(--accent, #6cf); transition: width 200ms ease;"></div>
|
||
</div>
|
||
<span style="flex: 1;"></span>
|
||
<button
|
||
class="refresh-btn"
|
||
onclick="triggerDeltaSync()"
|
||
id="delta-sync-btn"
|
||
title="Pulls only messages received since the last sync. Fast (~seconds). Use this for routine refreshes."
|
||
>Delta sync</button>
|
||
<button
|
||
class="refresh-btn"
|
||
onclick="triggerFullSync()"
|
||
id="full-sync-btn"
|
||
style="margin-left: 8px;"
|
||
title="Walks every joined chat from scratch and re-downloads all history + media. Slow (~1–2 min). Use after long absences, missing rooms, or to rebuild the cache."
|
||
>Full sync</button>
|
||
<button class="refresh-btn" onclick="fetchMedia()" id="refresh-media-btn" style="margin-left: 8px;">Refresh view</button>
|
||
<button class="unblur-all-btn" id="unblur-all-btn" onclick="toggleUnblurAll()" title="Toggle blur on all gallery images">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||
Unblur all
|
||
</button>
|
||
</div>
|
||
<div class="panel-content media-grid" id="media-grid">
|
||
<div class="empty-state">No media cached yet</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 06 — Console -->
|
||
<section class="section reveal reveal-7" data-page="dashboard">
|
||
<div class="section-eyebrow">
|
||
<span class="num">06</span>
|
||
<span class="title">Console</span>
|
||
<span class="rule"></span>
|
||
</div>
|
||
<div class="panels">
|
||
<div class="panel" style="grid-column: span 2;">
|
||
<div class="panel-header-row">
|
||
<span class="panel-title">Realtime log</span>
|
||
<div class="log-status">
|
||
<span class="log-status-dot" id="sse-status-dot"></span>
|
||
<span id="sse-status-text">Connecting…</span>
|
||
</div>
|
||
<button class="clear-btn" onclick="clearLogs()">Clear</button>
|
||
</div>
|
||
<div class="realtime-log" id="realtime-log">
|
||
<div class="empty-state">Waiting for log data…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 07 — Configuration -->
|
||
<section class="section reveal reveal-8" data-page="dashboard">
|
||
<div class="section-eyebrow">
|
||
<span class="num">07</span>
|
||
<span class="title">Configuration snapshot</span>
|
||
<span class="rule"></span>
|
||
</div>
|
||
<div class="config-section">
|
||
<div class="config-grid">
|
||
<div class="config-item" style="grid-column: 1 / -1;">
|
||
<h4>Monitoring subreddits</h4>
|
||
<div class="subreddit-editor" id="subreddit-editor"></div>
|
||
<div class="subreddit-editor-actions">
|
||
<button type="button" onclick="addSubredditRow()">+ Add subreddit</button>
|
||
<button type="button" class="primary" id="save-subreddits-btn" onclick="saveSubreddits()">Save changes</button>
|
||
<span class="subreddit-editor-status" id="subreddit-editor-status"></span>
|
||
</div>
|
||
</div>
|
||
<div class="config-item">
|
||
<h4>Target genders</h4>
|
||
<div class="value" id="config-genders"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Conversation modal -->
|
||
<div class="modal-overlay" id="conversation-modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2 id="modal-title">Conversation</h2>
|
||
<button class="close-btn" onclick="closeConversationModal()">×</button>
|
||
</div>
|
||
<div class="modal-body" id="modal-body">
|
||
<div class="empty-state">Loading…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Login (noVNC) modal -->
|
||
<div class="modal-overlay" id="login-modal">
|
||
<div class="modal-content" style="max-width: 1100px; width: 95%;">
|
||
<div class="modal-header">
|
||
<h2>Log in to Reddit</h2>
|
||
<button class="close-btn" onclick="closeLoginModal()">×</button>
|
||
</div>
|
||
<div class="modal-body" style="padding: 16px 20px;">
|
||
<div id="login-status-banner" style="margin-bottom: 12px; font-size: 13px; color: var(--ink-soft);">
|
||
Checking session status…
|
||
</div>
|
||
<div id="login-vnc-wrap" style="display: none;">
|
||
<div style="display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap;">
|
||
<button class="btn-primary" type="button" onclick="startLoginFlow()">Open Reddit login page</button>
|
||
<button class="btn-secondary" type="button" onclick="refreshLoginStatus()">Re-check session</button>
|
||
<a id="login-vnc-newtab" class="btn-secondary" target="_blank" rel="noopener" style="text-decoration: none; display: inline-flex; align-items: center;">Open VNC in new tab</a>
|
||
</div>
|
||
<div style="position: relative; width: 100%; height: 600px; background: #000; border-radius: var(--r-sm); overflow: hidden;">
|
||
<iframe id="login-vnc-frame" src="about:blank" style="width: 100%; height: 100%; border: 0;"></iframe>
|
||
</div>
|
||
<p style="margin-top: 10px; font-size: 12px; color: var(--ink-soft);">
|
||
Tip: once you see your Reddit username in the top-right of the embedded browser, click <strong>Re-check session</strong>. The bot will pick up the cookies automatically.
|
||
</p>
|
||
</div>
|
||
<div id="login-vnc-missing" style="display: none; padding: 14px; background: var(--card-2); border-radius: var(--r-sm); font-size: 13px;">
|
||
The VNC console isn't available. Set the <code>DISPLAY</code> env var (the Docker image does this automatically) and expose port <code>6080</code> to use this feature.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings modal -->
|
||
<div class="modal-overlay" id="settings-modal">
|
||
<div class="modal-content" style="max-width: 520px;">
|
||
<div class="modal-header">
|
||
<h2>Bot settings</h2>
|
||
<button class="close-btn" onclick="closeSettingsModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form class="settings-form" id="settings-form" onsubmit="saveSettings(event)">
|
||
<h3>Rate limits</h3>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="maxMessagesPerHour">Max messages / hour</label>
|
||
<input type="number" id="maxMessagesPerHour" min="1" max="100" value="8">
|
||
<span class="hint">Maximum messages to send per hour</span>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="minDelayMinutes">Min delay (minutes)</label>
|
||
<input type="number" id="minDelayMinutes" min="1" max="60" value="5">
|
||
<span class="hint">Minimum delay between messages</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="jitterSeconds">Jitter (seconds)</label>
|
||
<input type="number" id="jitterSeconds" min="0" max="300" value="60">
|
||
<span class="hint">Random delay added to make timing less predictable</span>
|
||
</div>
|
||
|
||
<h3>Post prioritisation</h3>
|
||
|
||
<div class="toggle-group">
|
||
<label>Prioritise recent posts — prefer fresh posts when scanning</label>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="prioritizeRecentPosts">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="recentWindowMinutes">Recent window (minutes)</label>
|
||
<input type="number" id="recentWindowMinutes" min="1" max="1440" value="60">
|
||
<span class="hint">Posts newer than this are preferred. Older posts are only used as a fallback when no recent ones exist.</span>
|
||
</div>
|
||
|
||
<h3>Mode</h3>
|
||
|
||
<div class="toggle-group">
|
||
<label>Dry run mode — don't actually send messages</label>
|
||
<label class="toggle">
|
||
<input type="checkbox" id="dryRunMode">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-secondary" onclick="closeSettingsModal()">Cancel</button>
|
||
<button type="submit" class="btn-primary" onclick="saveSettings(event)">Save settings</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="page-footer">
|
||
<span>Reddit Bot Console · <em>a quiet companion for warm-up conversations</em></span>
|
||
<span class="mono">v · live</span>
|
||
</footer>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
function formatTime(timestamp) {
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
const diff = now - date;
|
||
|
||
if (diff < 60000) return 'Just now';
|
||
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
||
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
function getActivityIcon(type) {
|
||
const icons = {
|
||
sent: '✉️',
|
||
skipped: '⏭️',
|
||
response: '💬',
|
||
error: '❌'
|
||
};
|
||
return icons[type] || '📌';
|
||
}
|
||
|
||
async function fetchStats() {
|
||
try {
|
||
const res = await fetch('/api/stats');
|
||
const stats = await res.json();
|
||
|
||
document.getElementById('stat-sent').textContent = stats.totalMessagesSent;
|
||
document.getElementById('stat-hour').textContent = stats.messagesThisHour;
|
||
document.getElementById('stat-max-hour').textContent = stats.maxMessagesPerHour;
|
||
document.getElementById('stat-responses').textContent = stats.totalResponses;
|
||
document.getElementById('stat-rate').textContent = stats.responseRate;
|
||
document.getElementById('stat-processed').textContent = stats.totalProcessed;
|
||
|
||
document.getElementById('dryrun-badge').style.display = stats.dryRunMode ? 'inline-flex' : 'none';
|
||
|
||
const banner = document.getElementById('cooldown-banner');
|
||
if (stats.invitationCooldown) {
|
||
const msLeft = stats.invitationCooldown.until - Date.now();
|
||
if (msLeft > 0) {
|
||
const minutes = Math.floor(msLeft / 60000);
|
||
const seconds = Math.floor((msLeft % 60000) / 1000);
|
||
const remaining = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||
document.getElementById('cooldown-reason').textContent = stats.invitationCooldown.reason;
|
||
document.getElementById('cooldown-remaining').textContent = remaining;
|
||
banner.style.display = 'block';
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
|
||
// Update running state
|
||
const runningBadge = document.getElementById('running-badge');
|
||
const stoppedBadge = document.getElementById('stopped-badge');
|
||
const controlBtn = document.getElementById('control-btn');
|
||
|
||
if (stats.isRunning) {
|
||
runningBadge.style.display = 'inline-flex';
|
||
stoppedBadge.style.display = 'none';
|
||
controlBtn.textContent = 'Stop Bot';
|
||
controlBtn.className = 'control-btn stop';
|
||
} else {
|
||
runningBadge.style.display = 'none';
|
||
stoppedBadge.style.display = 'inline-flex';
|
||
controlBtn.textContent = 'Start Bot';
|
||
controlBtn.className = 'control-btn start';
|
||
}
|
||
|
||
document.getElementById('config-genders').innerHTML = stats.targetGenders
|
||
.map(g => `<span class="tag">${g}</span>`).join('');
|
||
|
||
} catch (err) {
|
||
console.error('Failed to fetch stats:', err);
|
||
}
|
||
}
|
||
|
||
async function lookupUser() {
|
||
const input = document.getElementById('user-lookup-input');
|
||
const container = document.getElementById('user-lookup-results');
|
||
const username = input.value.trim().replace(/^u\//, '');
|
||
|
||
if (!username) {
|
||
container.classList.remove('visible');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/api/posts/by-author/${encodeURIComponent(username)}`);
|
||
const posts = await res.json();
|
||
|
||
if (posts.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="lookup-header">
|
||
<span>No posts found for u/${escapeHtml(username)}</span>
|
||
<button class="lookup-close" onclick="closeLookup()">×</button>
|
||
</div>`;
|
||
container.classList.add('visible');
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="lookup-header">
|
||
<span>Posts by u/${escapeHtml(username)} (${posts.length})</span>
|
||
<button class="lookup-close" onclick="closeLookup()">×</button>
|
||
</div>
|
||
${posts.map(post => {
|
||
const postUrl = `https://www.reddit.com/r/${escapeHtml(post.subreddit)}/comments/${escapeHtml(post.id)}`;
|
||
const status = post.error ? 'error' : post.messageSent ? 'sent' : 'pending';
|
||
const statusLabel = post.error ? post.error : post.messageSent ? 'Message sent' : 'Pending';
|
||
return `
|
||
<div class="lookup-post">
|
||
${post.imageUrl ? `<img class="lookup-post-image" src="${escapeHtml(post.imageUrl)}" alt="Post image" onclick="window.open(this.src, '_blank')" onerror="this.style.display='none'">` : ''}
|
||
<div class="lookup-post-info">
|
||
<div class="lookup-post-title"><a href="${postUrl}" target="_blank">${escapeHtml(post.title || 'Untitled')}</a></div>
|
||
<div class="lookup-post-meta">r/${escapeHtml(post.subreddit)} · ${formatTime(post.processedAt)}</div>
|
||
<span class="lookup-post-status ${status}">${escapeHtml(statusLabel)}</span>
|
||
</div>
|
||
</div>`;
|
||
}).join('')}`;
|
||
container.classList.add('visible');
|
||
|
||
} catch (err) {
|
||
console.error('User lookup failed:', err);
|
||
}
|
||
}
|
||
|
||
function closeLookup() {
|
||
document.getElementById('user-lookup-results').classList.remove('visible');
|
||
}
|
||
|
||
async function fetchActivity() {
|
||
try {
|
||
const res = await fetch('/api/activity');
|
||
const activity = await res.json();
|
||
|
||
const container = document.getElementById('activity-list');
|
||
|
||
if (activity.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No activity yet</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = activity.map(item => `
|
||
<div class="activity-item">
|
||
<div class="activity-icon ${item.type}">${getActivityIcon(item.type)}</div>
|
||
<div class="activity-content">
|
||
<div class="activity-message">${escapeHtml(item.message)}</div>
|
||
<div class="activity-time">${formatTime(item.timestamp)}</div>
|
||
</div>
|
||
${item.imageUrl ? `<img class="activity-thumb" src="${escapeHtml(item.imageUrl)}" alt="Post image" onclick="window.open(this.src, '_blank')" onerror="this.style.display='none'">` : ''}
|
||
</div>
|
||
`).join('');
|
||
|
||
} catch (err) {
|
||
console.error('Failed to fetch activity:', err);
|
||
}
|
||
}
|
||
|
||
async function fetchMessages() {
|
||
try {
|
||
const res = await fetch('/api/messages');
|
||
const messages = await res.json();
|
||
|
||
const container = document.getElementById('messages-list');
|
||
|
||
if (messages.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No messages sent yet</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = messages.map(msg => {
|
||
const postUrl = msg.subreddit
|
||
? `https://www.reddit.com/r/${escapeHtml(msg.subreddit)}/comments/${escapeHtml(msg.postId)}`
|
||
: `https://redd.it/${escapeHtml(msg.postId)}`;
|
||
|
||
const sourceLabel = msg.subreddit ? `r/${escapeHtml(msg.subreddit)}` : 'Post';
|
||
|
||
const postTitle = msg.postTitle
|
||
? escapeHtml(msg.postTitle.length > 60 ? msg.postTitle.slice(0, 60) + '...' : msg.postTitle)
|
||
: '';
|
||
|
||
const displayRecipient = formatUsernameRaw(msg.recipient);
|
||
|
||
const imageAnalysis = msg.postImageAnalysis
|
||
? `<details class="message-image-analysis">
|
||
<summary>Image analysis</summary>
|
||
<div class="message-image-analysis-body">${escapeHtml(msg.postImageAnalysis)}</div>
|
||
</details>`
|
||
: '';
|
||
|
||
return `
|
||
<div class="message-item">
|
||
<div class="message-recipient">
|
||
u/${escapeHtml(displayRecipient)}
|
||
${msg.gotResponse ? '<span class="response-badge">Got Response!</span>' : ''}
|
||
<a href="${postUrl}" target="_blank" style="margin-left:auto; color: var(--ink-soft); font-size: 11px; text-decoration: none; border: 1px solid var(--rule); padding: 3px 9px; border-radius: 999px; font-family: var(--f-mono);">
|
||
${sourceLabel} ↗
|
||
</a>
|
||
</div>
|
||
${postTitle ? `<div style="color: var(--ink-soft); font-size: 12px; margin-bottom: 6px; font-style: italic; font-family: var(--f-display);">Re: ${postTitle}</div>` : ''}
|
||
<div class="message-opener">
|
||
<span class="message-section-label">Opener sent</span>
|
||
<div class="message-text">${escapeHtml(msg.message)}</div>
|
||
</div>
|
||
${imageAnalysis}
|
||
<div class="message-meta">
|
||
<span>${formatTime(msg.sentAt)}</span>
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
|
||
} catch (err) {
|
||
console.error('Failed to fetch messages:', err);
|
||
}
|
||
}
|
||
|
||
async function fetchSuccessful() {
|
||
try {
|
||
const res = await fetch('/api/successful');
|
||
const successful = await res.json();
|
||
document.getElementById('stat-examples').textContent = successful.length;
|
||
} catch (err) {
|
||
console.error('Failed to fetch successful:', err);
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
/* ── Sanitise a username for safe display. t2_* and Matrix IDs are
|
||
Reddit platform identifiers — strip the t2_ prefix. Real usernames
|
||
do not start with t2_. */
|
||
function formatUsernameRaw(username) {
|
||
if (!username || username === 'unknown') return 'unknown';
|
||
// Strip the full MXID — handles @t2_abc123:reddit.com → t2_abc123
|
||
const mxidMatch = username.match(/^@(t2_[^:]+):/);
|
||
if (mxidMatch) return mxidMatch[1];
|
||
// t2_* is always a Matrix/platform ID — strip the prefix
|
||
const t2Match = username.replace(/^t2_/, '');
|
||
return t2Match;
|
||
}
|
||
|
||
async function toggleBot() {
|
||
const controlBtn = document.getElementById('control-btn');
|
||
const isCurrentlyRunning = controlBtn.classList.contains('stop');
|
||
|
||
try {
|
||
const endpoint = isCurrentlyRunning ? '/api/stop' : '/api/start';
|
||
const res = await fetch(endpoint, { method: 'POST' });
|
||
const result = await res.json();
|
||
|
||
if (result.success) {
|
||
fetchStats();
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to toggle bot:', err);
|
||
}
|
||
}
|
||
|
||
async function fetchFromReddit() {
|
||
const fetchBtn = document.getElementById('fetch-reddit-btn');
|
||
const container = document.getElementById('chats-list');
|
||
|
||
try {
|
||
if (fetchBtn) {
|
||
fetchBtn.disabled = true;
|
||
fetchBtn.textContent = '⏳ Fetching…';
|
||
}
|
||
container.innerHTML = '<div class="empty-state">Fetching conversations from Reddit… This may take a moment.</div>';
|
||
|
||
const res = await fetch('/api/chats/fetch-all', { method: 'POST' });
|
||
const result = await res.json();
|
||
|
||
if (result.success) {
|
||
await fetchChats({ skipSync: true });
|
||
await fetchConversations();
|
||
await fetchMedia();
|
||
} else {
|
||
container.innerHTML = '<div class="empty-state">Failed to fetch from Reddit: ' + (result.error || 'Unknown error') + '</div>';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to fetch from Reddit:', err);
|
||
container.innerHTML = '<div class="empty-state">Failed to fetch from Reddit</div>';
|
||
} finally {
|
||
if (fetchBtn) {
|
||
fetchBtn.disabled = false;
|
||
fetchBtn.textContent = '🌐 Fetch from Reddit';
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchChats({ skipSync = false } = {}) {
|
||
const container = document.getElementById('chats-list');
|
||
const refreshBtn = document.getElementById('refresh-chats-btn');
|
||
|
||
try {
|
||
if (refreshBtn) {
|
||
refreshBtn.disabled = true;
|
||
refreshBtn.textContent = '⏳ Syncing…';
|
||
}
|
||
|
||
if (!skipSync) {
|
||
try {
|
||
const syncRes = await fetch('/api/chats/sync/delta', { method: 'POST' });
|
||
const syncResult = await syncRes.json().catch(() => ({}));
|
||
if (syncResult && syncResult.success) {
|
||
// Poll until the background delta sync finishes so the cache reflects new messages.
|
||
const deadline = Date.now() + 30_000;
|
||
while (Date.now() < deadline) {
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
const s = await (await fetch('/api/chats/sync')).json().catch(() => null);
|
||
if (!s || !s.syncing) break;
|
||
}
|
||
await refreshChatSyncStatus();
|
||
}
|
||
} catch (syncErr) {
|
||
console.warn('Refresh: delta sync failed, falling back to cached view', syncErr);
|
||
}
|
||
}
|
||
|
||
if (refreshBtn) refreshBtn.textContent = '⏳ Loading…';
|
||
|
||
const res = await fetch('/api/chats');
|
||
const chats = await res.json();
|
||
|
||
if (chats.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No active chats cached. Click "🌐 Fetch from Reddit" to load conversations.</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = chats.map(chat => {
|
||
const chatUrl = chat.url || 'https://chat.reddit.com';
|
||
const display = formatUsernameRaw(chat.username);
|
||
const displayName = display === 'unknown' ? 'unknown' : 'u/' + escapeHtml(display);
|
||
const initial = display.charAt(0).toUpperCase() || '?';
|
||
return `
|
||
<div class="chat-item ${chat.unread ? 'unread' : ''}" style="cursor: pointer;" onclick="openConversation('${escapeHtml(chat.username)}')">
|
||
<div class="chat-avatar">${initial}</div>
|
||
<div class="chat-content">
|
||
<div class="chat-header">
|
||
<span class="chat-username">
|
||
${displayName}
|
||
<a href="${chatUrl}" target="_blank" rel="noopener" title="Open in Reddit" style="text-decoration: none; margin-left: 4px;" onclick="event.stopPropagation()">↗</a>
|
||
${chat.unread ? '<span class="unread-badge">Unread</span>' : ''}
|
||
</span>
|
||
<span class="chat-time">${formatTime(chat.lastMessageTime)}</span>
|
||
</div>
|
||
<div class="chat-snippet">${escapeHtml(chat.lastMessageSnippet || '(No message preview)')}</div>
|
||
<div class="chat-direction ${chat.isFromUs ? 'from-us' : 'from-them'}">
|
||
${chat.isFromUs ? '↗ You sent' : '↙ They replied'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
|
||
} catch (err) {
|
||
console.error('Failed to fetch chats:', err);
|
||
container.innerHTML = '<div class="empty-state">Failed to load chats</div>';
|
||
} finally {
|
||
if (refreshBtn) {
|
||
refreshBtn.disabled = false;
|
||
refreshBtn.textContent = '🔄 Refresh';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ───────── Chat sync controls ─────────
|
||
* • Delta: pulls only events since the last cursor. Seconds.
|
||
* • Full: walks every joined room from scratch. Minutes.
|
||
* Status banner under the header shows which is running and when each
|
||
* last completed. Poll runs continuously while the page is open.
|
||
*/
|
||
function fmtAgo(ts) {
|
||
if (!ts) return 'never';
|
||
const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||
if (sec < 60) return sec + 's ago';
|
||
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
||
return Math.floor(sec / 86400) + 'd ago';
|
||
}
|
||
|
||
async function refreshChatSyncStatus() {
|
||
try {
|
||
const res = await fetch('/api/chats/sync');
|
||
if (!res.ok) return;
|
||
const s = await res.json();
|
||
const banner = document.getElementById('chat-sync-status');
|
||
const bannerMain = document.getElementById('chat-sync-status-main');
|
||
const deltaBtn = document.getElementById('delta-sync-btn');
|
||
const fullBtn = document.getElementById('full-sync-btn');
|
||
if (!banner || !deltaBtn || !fullBtn) return;
|
||
|
||
const syncing = s.syncing;
|
||
deltaBtn.disabled = syncing;
|
||
fullBtn.disabled = syncing;
|
||
if (syncing) {
|
||
deltaBtn.textContent = s.syncingKind === 'delta' ? 'Syncing…' : 'Delta sync';
|
||
fullBtn.textContent = s.syncingKind === 'full' ? 'Syncing…' : 'Full sync';
|
||
} else {
|
||
deltaBtn.textContent = 'Delta sync';
|
||
fullBtn.textContent = 'Full sync';
|
||
}
|
||
|
||
// If a sync is in flight, the realtime progress frame is the
|
||
// authoritative source of detail — render it and bail.
|
||
if (syncing && s.progress) {
|
||
applySyncProgress(s.progress);
|
||
return;
|
||
}
|
||
|
||
const bar = document.getElementById('sync-progress-bar');
|
||
const barMain = document.getElementById('sync-progress-bar-main');
|
||
if (bar) bar.style.display = 'none';
|
||
if (barMain) barMain.style.display = 'none';
|
||
|
||
const parts = [
|
||
s.cachedChats + ' chats cached',
|
||
'last delta: ' + fmtAgo(s.lastDeltaSyncAt),
|
||
'last full: ' + fmtAgo(s.lastFullSyncAt),
|
||
];
|
||
if (syncing) parts.unshift((s.syncingKind === 'full' ? 'Full' : 'Delta') + ' sync running');
|
||
const text = parts.join(' • ');
|
||
banner.textContent = text;
|
||
if (bannerMain) bannerMain.textContent = syncing ? text : '';
|
||
} catch (err) {
|
||
console.error('chat sync status:', err);
|
||
}
|
||
}
|
||
|
||
const SYNC_PHASE_LABELS = {
|
||
'starting': 'Starting',
|
||
'seeding': 'Seeding from /sync',
|
||
'listing-rooms': 'Listing joined rooms',
|
||
'resolving-usernames': 'Resolving usernames',
|
||
'loading-details': 'Loading chat history',
|
||
'finalizing': 'Finalizing',
|
||
'done': 'Done',
|
||
};
|
||
|
||
function applySyncProgress(progress) {
|
||
const banner = document.getElementById('chat-sync-status');
|
||
const bannerMain = document.getElementById('chat-sync-status-main');
|
||
const bar = document.getElementById('sync-progress-bar');
|
||
const barMain = document.getElementById('sync-progress-bar-main');
|
||
const fill = document.getElementById('sync-progress-fill');
|
||
const fillMain = document.getElementById('sync-progress-fill-main');
|
||
const deltaBtn = document.getElementById('delta-sync-btn');
|
||
const fullBtn = document.getElementById('full-sync-btn');
|
||
if (!banner || !bar || !fill) return;
|
||
|
||
if (!progress) {
|
||
bar.style.display = 'none';
|
||
fill.style.width = '0%';
|
||
if (barMain) barMain.style.display = 'none';
|
||
if (fillMain) fillMain.style.width = '0%';
|
||
if (bannerMain) bannerMain.textContent = '';
|
||
if (deltaBtn) deltaBtn.disabled = false;
|
||
if (fullBtn) fullBtn.disabled = false;
|
||
if (deltaBtn) deltaBtn.textContent = 'Delta sync';
|
||
if (fullBtn) fullBtn.textContent = 'Full sync';
|
||
// Pull a fresh status snapshot so the "last full/delta" timestamps update.
|
||
refreshChatSyncStatus();
|
||
return;
|
||
}
|
||
|
||
if (deltaBtn) deltaBtn.disabled = true;
|
||
if (fullBtn) fullBtn.disabled = true;
|
||
if (deltaBtn) deltaBtn.textContent = progress.kind === 'delta' ? 'Syncing…' : 'Delta sync';
|
||
if (fullBtn) fullBtn.textContent = progress.kind === 'full' ? 'Syncing…' : 'Full sync';
|
||
|
||
const phaseLabel = SYNC_PHASE_LABELS[progress.phase] || progress.phase;
|
||
const elapsed = Math.max(0, Math.floor((Date.now() - progress.startedAt) / 1000));
|
||
const elapsedStr = elapsed < 60 ? elapsed + 's' : Math.floor(elapsed / 60) + 'm' + (elapsed % 60) + 's';
|
||
|
||
const parts = [
|
||
(progress.kind === 'full' ? 'Full' : 'Delta') + ' sync',
|
||
phaseLabel,
|
||
];
|
||
if (progress.total > 0) {
|
||
parts.push(progress.current + ' / ' + progress.total);
|
||
}
|
||
if (progress.label) {
|
||
parts.push('— ' + progress.label);
|
||
}
|
||
parts.push('(' + elapsedStr + ')');
|
||
const text = parts.join(' • ');
|
||
banner.textContent = text;
|
||
if (bannerMain) bannerMain.textContent = text;
|
||
|
||
if (progress.total > 0) {
|
||
const pct = Math.min(100, (progress.current / progress.total) * 100);
|
||
bar.style.display = 'block';
|
||
fill.style.width = pct + '%';
|
||
if (barMain) barMain.style.display = 'block';
|
||
if (fillMain) fillMain.style.width = pct + '%';
|
||
} else {
|
||
bar.style.display = 'block';
|
||
fill.style.width = '5%';
|
||
if (barMain) barMain.style.display = 'block';
|
||
if (fillMain) fillMain.style.width = '5%';
|
||
}
|
||
}
|
||
|
||
async function triggerDeltaSync() {
|
||
try {
|
||
const res = await fetch('/api/chats/sync/delta', { method: 'POST' });
|
||
const result = await res.json();
|
||
if (!result.success) alert(result.reason || 'Delta sync failed');
|
||
await refreshChatSyncStatus();
|
||
} catch (err) {
|
||
console.error('Delta sync failed:', err);
|
||
}
|
||
}
|
||
|
||
async function triggerFullSync() {
|
||
if (!confirm('Full sync walks every chat from scratch and can take 1–2 minutes for hundreds of rooms. Continue?')) return;
|
||
try {
|
||
const res = await fetch('/api/chats/sync/full', { method: 'POST' });
|
||
const result = await res.json();
|
||
if (!result.success) alert(result.reason || 'Full sync failed');
|
||
await refreshChatSyncStatus();
|
||
// Refresh media grid once the sync ends.
|
||
const poll = setInterval(async () => {
|
||
await refreshChatSyncStatus();
|
||
const s = await (await fetch('/api/chats/sync')).json();
|
||
if (!s.syncing) {
|
||
clearInterval(poll);
|
||
await fetchMedia();
|
||
}
|
||
}, 4000);
|
||
} catch (err) {
|
||
console.error('Full sync failed:', err);
|
||
}
|
||
}
|
||
|
||
// Boot the status poller.
|
||
refreshChatSyncStatus();
|
||
setInterval(refreshChatSyncStatus, 5000);
|
||
|
||
async function fetchMedia() {
|
||
const container = document.getElementById('media-grid');
|
||
const refreshBtn = document.getElementById('refresh-media-btn');
|
||
|
||
try {
|
||
if (refreshBtn) {
|
||
refreshBtn.disabled = true;
|
||
refreshBtn.textContent = 'Loading…';
|
||
}
|
||
|
||
const res = await fetch('/api/media');
|
||
if (!res.ok) throw new Error('Failed to fetch media');
|
||
const items = await res.json();
|
||
|
||
if (!items || items.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No media cached yet</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = items.map(item => {
|
||
const username = formatUsernameRaw(item.chatUsername || item.sender || '');
|
||
const timestamp = item.createdAt || item.timestamp || Date.now();
|
||
const mediaUrl = item.urlPath || '';
|
||
const rawUser = item.chatUsername || item.sender || '';
|
||
const userLabel = rawUser ? 'u/' + escapeHtml(username) : 'unknown sender';
|
||
const userClick = rawUser
|
||
? `onclick="event.stopPropagation(); openConversation('${escapeHtml(username)}')" style="cursor: pointer; text-decoration: underline;"`
|
||
: '';
|
||
|
||
return `
|
||
<div class="media-card">
|
||
<a href="${mediaUrl}" target="_blank" rel="noopener" title="Open image">
|
||
<img src="${mediaUrl}" alt="Chat media from ${escapeHtml(username || 'unknown')}" loading="lazy" />
|
||
</a>
|
||
<div class="media-meta">
|
||
<span class="media-user" ${userClick} title="${rawUser ? 'Open chat' : 'Recipient unknown'}">${userLabel}</span>
|
||
<span>${formatTime(timestamp)}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
} catch (err) {
|
||
console.error('Failed to fetch media:', err);
|
||
container.innerHTML = '<div class="empty-state">Failed to load media</div>';
|
||
} finally {
|
||
if (refreshBtn) {
|
||
refreshBtn.disabled = false;
|
||
refreshBtn.textContent = 'Refresh';
|
||
}
|
||
}
|
||
}
|
||
|
||
let isUnblurAll = false;
|
||
|
||
function toggleUnblurAll() {
|
||
isUnblurAll = !isUnblurAll;
|
||
const grid = document.getElementById('media-grid');
|
||
const btn = document.getElementById('unblur-all-btn');
|
||
if (isUnblurAll) {
|
||
grid.classList.add('revealed-all');
|
||
btn.classList.add('active');
|
||
} else {
|
||
grid.classList.remove('revealed-all');
|
||
btn.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
// Timer Logic
|
||
let timerState = {
|
||
nextMessageAvailableAt: null,
|
||
hourlyResetAt: Date.now() + 3600000,
|
||
isHourlyLimitReached: false,
|
||
messagesRemaining: 0
|
||
};
|
||
|
||
async function fetchTimers() {
|
||
try {
|
||
const res = await fetch('/api/timers');
|
||
if (res.ok) {
|
||
timerState = await res.json();
|
||
updateTimerUI();
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to fetch timers:', err);
|
||
}
|
||
}
|
||
|
||
function updateTimerUI() {
|
||
const now = Date.now();
|
||
|
||
const nextMsgEl = document.getElementById('timer-next-msg');
|
||
const nextMsgProgress = document.getElementById('progress-next-msg');
|
||
const nextMsgStatus = document.getElementById('status-next-msg');
|
||
|
||
if (timerState.nextMessageAvailableAt && timerState.nextMessageAvailableAt > now) {
|
||
const diff = timerState.nextMessageAvailableAt - now;
|
||
const totalDuration = 5 * 60 * 1000;
|
||
const pct = Math.min(100, (diff / totalDuration) * 100);
|
||
|
||
nextMsgEl.textContent = formatDuration(diff);
|
||
nextMsgProgress.style.width = pct + '%';
|
||
nextMsgStatus.textContent = 'Cooling down…';
|
||
} else {
|
||
nextMsgEl.textContent = 'Ready';
|
||
nextMsgProgress.style.width = '0%';
|
||
nextMsgStatus.textContent = timerState.isHourlyLimitReached
|
||
? 'Hourly limit reached'
|
||
: 'Ready to send next message';
|
||
}
|
||
|
||
const hourlyEl = document.getElementById('timer-hourly');
|
||
const hourlyProgress = document.getElementById('progress-hourly');
|
||
|
||
const hourlyDiff = Math.max(0, timerState.hourlyResetAt - now);
|
||
hourlyEl.textContent = formatDuration(hourlyDiff);
|
||
|
||
const hourlyPct = (hourlyDiff / 3600000) * 100;
|
||
hourlyProgress.style.width = hourlyPct + '%';
|
||
|
||
document.getElementById('msgs-remaining').textContent = timerState.messagesRemaining;
|
||
|
||
if (timerState.messagesRemaining === 0) {
|
||
hourlyProgress.className = 'timer-progress-fill danger';
|
||
} else if (timerState.messagesRemaining <= 2) {
|
||
hourlyProgress.className = 'timer-progress-fill warning';
|
||
} else {
|
||
hourlyProgress.className = 'timer-progress-fill';
|
||
}
|
||
}
|
||
|
||
function formatDuration(ms) {
|
||
if (ms < 0) return '00:00';
|
||
const minutes = Math.floor(ms / 60000);
|
||
const seconds = Math.floor((ms % 60000) / 1000);
|
||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
// Initial fetch
|
||
fetchStats();
|
||
fetchTimers();
|
||
fetchActivity();
|
||
fetchMessages();
|
||
fetchSuccessful();
|
||
fetchChats({ skipSync: true });
|
||
fetchConversations();
|
||
fetchMedia();
|
||
|
||
setInterval(() => {
|
||
fetchStats();
|
||
fetchTimers();
|
||
fetchActivity();
|
||
fetchMessages();
|
||
fetchSuccessful();
|
||
}, 5000);
|
||
|
||
setInterval(updateTimerUI, 1000);
|
||
|
||
let cachedConversations = [];
|
||
|
||
async function fetchConversations() {
|
||
const container = document.getElementById('conversations-list');
|
||
const refreshBtn = document.getElementById('refresh-conversations-btn');
|
||
|
||
try {
|
||
if (refreshBtn) {
|
||
refreshBtn.disabled = true;
|
||
refreshBtn.textContent = '⏳ Loading…';
|
||
}
|
||
|
||
const res = await fetch('/api/conversations');
|
||
cachedConversations = await res.json();
|
||
renderConversations();
|
||
} catch (err) {
|
||
console.error('Failed to fetch conversations:', err);
|
||
container.innerHTML = '<div class="empty-state">Failed to load cached conversations</div>';
|
||
} finally {
|
||
if (refreshBtn) {
|
||
refreshBtn.disabled = false;
|
||
refreshBtn.textContent = '🔄 Refresh';
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderConversations() {
|
||
const container = document.getElementById('conversations-list');
|
||
const filterEl = document.getElementById('conversations-filter');
|
||
const filter = filterEl ? filterEl.value : 'all';
|
||
|
||
if (!cachedConversations.length) {
|
||
container.innerHTML = '<div class="empty-state">No cached conversations. Click "Fetch All" in Active Chats to populate the cache.</div>';
|
||
return;
|
||
}
|
||
|
||
const filtered = cachedConversations.filter(conv => {
|
||
const hasReply = conv.messages ? conv.messages.some(m => !m.isSelf) : false;
|
||
if (filter === 'replied') return hasReply;
|
||
if (filter === 'no-reply') return !hasReply;
|
||
return true;
|
||
});
|
||
|
||
if (!filtered.length) {
|
||
container.innerHTML = `<div class="empty-state">No conversations match the "${filter}" filter.</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = filtered.map(conv => {
|
||
const display = formatUsernameRaw(conv.username);
|
||
const displayName = display === 'unknown' ? 'unknown' : 'u/' + escapeHtml(display);
|
||
const initial = display.charAt(0).toUpperCase() || '?';
|
||
const messageCount = conv.messages ? conv.messages.length : 0;
|
||
const hasReply = conv.messages ? conv.messages.some(m => !m.isSelf) : false;
|
||
|
||
const origin = conv.originMessage ? conv.originMessage.slice(0, 60) + '...' : conv.lastMessageSnippet;
|
||
|
||
let cardClass = 'conversation-card';
|
||
if (hasReply) cardClass += ' has-reply';
|
||
|
||
const statusBadge = hasReply ? '<span class="reply-indicator">Got Reply!</span>' : '';
|
||
|
||
return `
|
||
<div class="${cardClass}" onclick="openConversation('${escapeHtml(conv.username)}')">
|
||
<div class="chat-avatar">${initial}</div>
|
||
<div class="chat-content">
|
||
<div class="chat-header">
|
||
<span class="chat-username">
|
||
${displayName}
|
||
<span class="message-count-badge">${messageCount} msgs</span>
|
||
${statusBadge}
|
||
</span>
|
||
<span class="chat-time">${formatTime(conv.lastMessageTime)}</span>
|
||
</div>
|
||
<div class="chat-snippet">${escapeHtml(origin || '(No preview)')}</div>
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
}
|
||
|
||
let openConversationUsername = null;
|
||
let openConversationSyncWatch = null;
|
||
|
||
async function openConversation(username) {
|
||
const modal = document.getElementById('conversation-modal');
|
||
const modalTitle = document.getElementById('modal-title');
|
||
const modalBody = document.getElementById('modal-body');
|
||
|
||
openConversationUsername = username;
|
||
modalTitle.textContent = `Conversation with u/${formatUsernameRaw(username)}`;
|
||
// If a sync is in flight, set a clearer placeholder up front instead of bare "Loading…".
|
||
let placeholderText = 'Loading…';
|
||
try {
|
||
const sRes = await fetch('/api/chats/sync');
|
||
if (sRes.ok) {
|
||
const s = await sRes.json();
|
||
if (s.syncing && s.progress) {
|
||
const phase = SYNC_PHASE_LABELS[s.progress.phase] || s.progress.phase;
|
||
const counter = s.progress.total > 0
|
||
? ` (${s.progress.current} / ${s.progress.total})`
|
||
: '';
|
||
placeholderText = `${s.syncingKind === 'full' ? 'Full' : 'Delta'} sync running — ${phase}${counter}. Loading cached messages…`;
|
||
}
|
||
}
|
||
} catch (_) { /* non-blocking */ }
|
||
modalBody.innerHTML = `<div class="empty-state">${escapeHtml(placeholderText)}</div>`;
|
||
modal.classList.add('active');
|
||
|
||
try {
|
||
const res = await fetch(`/api/conversations/${encodeURIComponent(username)}`);
|
||
if (!res.ok) throw new Error('Conversation not found');
|
||
const conv = await res.json();
|
||
|
||
let metaHtml = '<div class="conversation-meta">';
|
||
metaHtml += `<div class="meta-item">Room: <span>${escapeHtml(formatUsernameRaw(conv.roomName))}</span></div>`;
|
||
metaHtml += `<div class="meta-item">Last activity: <span>${formatTime(conv.lastMessageTime)}</span></div>`;
|
||
if (conv.url) {
|
||
metaHtml += `<div class="meta-item"><a href="${conv.url}" target="_blank" rel="noopener">Open in Reddit ↗</a></div>`;
|
||
}
|
||
if (conv.triggeringPost) {
|
||
const tp = conv.triggeringPost;
|
||
const label = tp.title ? escapeHtml(tp.title) : `Post ${escapeHtml(tp.id)}`;
|
||
const sub = tp.subreddit ? ` <span style="color: var(--ink-faint);">r/${escapeHtml(tp.subreddit)}</span>` : '';
|
||
metaHtml += `<div class="meta-item">Original post: <a href="${escapeHtml(tp.url)}" target="_blank" rel="noopener" title="Jump to the post that triggered this outreach">${label} ↗</a>${sub}</div>`;
|
||
}
|
||
metaHtml += '</div>';
|
||
|
||
let syncBannerHtml = '';
|
||
if (conv.syncInProgress) {
|
||
const sip = conv.syncInProgress;
|
||
const phase = sip.progress
|
||
? (SYNC_PHASE_LABELS[sip.progress.phase] || sip.progress.phase)
|
||
: '';
|
||
const counter = sip.progress && sip.progress.total > 0
|
||
? ` (${sip.progress.current} / ${sip.progress.total})`
|
||
: '';
|
||
const kindLabel = sip.kind === 'full' ? 'Full' : 'Delta';
|
||
const note = sip.staleMessages
|
||
? 'Showing cached messages — newer history will load once sync finishes.'
|
||
: 'Newer messages may appear once sync finishes.';
|
||
syncBannerHtml = `
|
||
<div class="meta-item" style="background: rgba(255,204,102,0.12); border: 1px solid rgba(255,204,102,0.3); padding: 8px 12px; border-radius: 6px; margin-bottom: 8px;">
|
||
<strong>${kindLabel} sync in progress</strong> — ${escapeHtml(phase)}${escapeHtml(counter)}. ${escapeHtml(note)}
|
||
</div>
|
||
`;
|
||
startConversationSyncWatch(username);
|
||
} else {
|
||
stopConversationSyncWatch();
|
||
}
|
||
|
||
let messagesHtml = '';
|
||
if (conv.messages && conv.messages.length > 0) {
|
||
messagesHtml = conv.messages.map(msg => {
|
||
const bubbleClass = msg.isSelf ? 'sent' : 'received';
|
||
const senderLabel = msg.isSelf ? 'You' : escapeHtml(formatUsernameRaw(msg.sender));
|
||
const hasMedia = Array.isArray(msg.media) && msg.media.length > 0;
|
||
// Suppress the "[Image]" placeholder text when we have the image to render.
|
||
const showText = msg.text && !(hasMedia && msg.text.trim() === '[Image]');
|
||
const mediaHtml = hasMedia
|
||
? `<div class="media">${msg.media.map(m => {
|
||
const src = `/media/${encodeURIComponent(m.localPath)}`;
|
||
return `<a href="${src}" target="_blank" rel="noopener"><img src="${src}" alt="${escapeHtml(m.fileName || 'image')}" loading="lazy" onerror="this.parentElement.style.display='none'"></a>`;
|
||
}).join('')}</div>`
|
||
: '';
|
||
return `
|
||
<div class="message-bubble ${bubbleClass}">
|
||
<div class="sender">${escapeHtml(senderLabel)}</div>
|
||
<div class="text">${showText ? escapeHtml(msg.text) : ''}</div>
|
||
${mediaHtml}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
} else {
|
||
messagesHtml = '<div class="no-messages">No messages in this conversation yet.</div>';
|
||
}
|
||
|
||
const hasReply = conv.messages && conv.messages.some(m => !m.isSelf);
|
||
const safeUsername = escapeHtml(conv.username);
|
||
let replyHtml = '';
|
||
if (hasReply) {
|
||
replyHtml = `
|
||
<div id="suggest-reply-container">
|
||
<div class="reply-editor">
|
||
<textarea id="reply-text" placeholder="Type a reply, or click ✨ Generate to draft one with AI…"></textarea>
|
||
<div class="reply-actions">
|
||
<button class="suggest-btn" onclick="suggestReply('${safeUsername}')">✨ Generate reply</button>
|
||
<button class="suggest-btn" onclick="suggestMediaAsk('${safeUsername}')">📸 Ask for pic</button>
|
||
<button class="send-btn" onclick="sendReply('${safeUsername}')">Send</button>
|
||
<span class="send-status" id="send-status"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
replyHtml = '<div class="no-messages">Waiting for them to reply before you can respond here.</div>';
|
||
}
|
||
|
||
modalBody.innerHTML = metaHtml + syncBannerHtml + messagesHtml + replyHtml;
|
||
|
||
} catch (err) {
|
||
console.error('Failed to load conversation:', err);
|
||
modalBody.innerHTML = '<div class="empty-state">Failed to load conversation details</div>';
|
||
}
|
||
}
|
||
|
||
async function generateIntoTextarea(username, endpoint, btn, regenerateLabel) {
|
||
const textarea = document.getElementById('reply-text');
|
||
const status = document.getElementById('send-status');
|
||
const originalLabel = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Generating…';
|
||
status.style.color = 'var(--honey)';
|
||
status.textContent = 'Asking AI…';
|
||
|
||
try {
|
||
const res = await fetch(`/api/conversations/${encodeURIComponent(username)}/${endpoint}`, { method: 'POST' });
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || 'Failed to generate');
|
||
}
|
||
const data = await res.json();
|
||
textarea.value = data.suggestedReply || '';
|
||
textarea.focus();
|
||
status.style.color = 'var(--ink-faint)';
|
||
status.textContent = 'Draft ready — edit then Send.';
|
||
btn.textContent = regenerateLabel;
|
||
} catch (err) {
|
||
console.error('Generation failed:', err);
|
||
status.style.color = 'var(--clay)';
|
||
status.textContent = err.message;
|
||
btn.textContent = originalLabel;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function suggestReply(username) {
|
||
const btn = document.querySelector('#suggest-reply-container .suggest-btn[onclick^="suggestReply"]');
|
||
await generateIntoTextarea(username, 'suggest-reply', btn, '✨ Regenerate');
|
||
}
|
||
|
||
async function suggestMediaAsk(username) {
|
||
const btn = document.querySelector('#suggest-reply-container .suggest-btn[onclick^="suggestMediaAsk"]');
|
||
await generateIntoTextarea(username, 'suggest-media-ask', btn, '📸 Regenerate');
|
||
}
|
||
|
||
async function sendReply(username) {
|
||
const textarea = document.getElementById('reply-text');
|
||
const sendBtn = document.querySelector('.send-btn');
|
||
const status = document.getElementById('send-status');
|
||
const message = textarea.value.trim();
|
||
|
||
if (!message) {
|
||
status.style.color = 'var(--clay)';
|
||
status.textContent = 'Message cannot be empty';
|
||
return;
|
||
}
|
||
|
||
sendBtn.disabled = true;
|
||
sendBtn.textContent = 'Sending…';
|
||
status.style.color = 'var(--honey)';
|
||
status.textContent = 'Navigating to chat and sending…';
|
||
|
||
try {
|
||
const res = await fetch(`/api/conversations/${encodeURIComponent(username)}/send-reply`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ message }),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || 'Failed to send');
|
||
}
|
||
|
||
status.style.color = 'var(--sage-deep)';
|
||
status.textContent = 'Sent!';
|
||
sendBtn.textContent = 'Sent';
|
||
textarea.disabled = true;
|
||
} catch (err) {
|
||
console.error('Failed to send reply:', err);
|
||
status.style.color = 'var(--clay)';
|
||
status.textContent = err.message;
|
||
sendBtn.disabled = false;
|
||
sendBtn.textContent = 'Send';
|
||
}
|
||
}
|
||
|
||
function closeConversationModal() {
|
||
document.getElementById('conversation-modal').classList.remove('active');
|
||
openConversationUsername = null;
|
||
stopConversationSyncWatch();
|
||
}
|
||
|
||
// Poll the sync status while the modal is open under an active sync; when
|
||
// the sync ends, re-fetch the conversation so the user sees the freshly
|
||
// loaded history without having to close + reopen.
|
||
function startConversationSyncWatch(username) {
|
||
stopConversationSyncWatch();
|
||
openConversationSyncWatch = setInterval(async () => {
|
||
if (openConversationUsername !== username) {
|
||
stopConversationSyncWatch();
|
||
return;
|
||
}
|
||
try {
|
||
const r = await fetch('/api/chats/sync');
|
||
if (!r.ok) return;
|
||
const s = await r.json();
|
||
if (!s.syncing) {
|
||
stopConversationSyncWatch();
|
||
// Sync finished — reopen to pull fresh history.
|
||
if (openConversationUsername === username) {
|
||
openConversation(username);
|
||
}
|
||
}
|
||
} catch (_) { /* swallow; next tick will try again */ }
|
||
}, 3000);
|
||
}
|
||
|
||
function stopConversationSyncWatch() {
|
||
if (openConversationSyncWatch) {
|
||
clearInterval(openConversationSyncWatch);
|
||
openConversationSyncWatch = null;
|
||
}
|
||
}
|
||
|
||
document.getElementById('conversation-modal').addEventListener('click', function (e) {
|
||
if (e.target === this) {
|
||
closeConversationModal();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') {
|
||
closeConversationModal();
|
||
closeSettingsModal();
|
||
}
|
||
});
|
||
|
||
// =====================================================
|
||
// REALTIME LOG & SSE CONNECTION
|
||
// =====================================================
|
||
let eventSource = null;
|
||
const logContainer = document.getElementById('realtime-log');
|
||
const sseStatusDot = document.getElementById('sse-status-dot');
|
||
const sseStatusText = document.getElementById('sse-status-text');
|
||
const logEntries = [];
|
||
const MAX_LOG_ENTRIES = 200;
|
||
|
||
function formatLogTime(timestamp) {
|
||
const date = new Date(timestamp);
|
||
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
}
|
||
|
||
function renderLogs() {
|
||
if (logEntries.length === 0) {
|
||
logContainer.innerHTML = '<div class="empty-state">Waiting for log data…</div>';
|
||
return;
|
||
}
|
||
|
||
logContainer.innerHTML = logEntries.map(entry => `
|
||
<div class="log-entry ${entry.level}">
|
||
<span class="log-time">${formatLogTime(entry.timestamp)}</span>
|
||
<span class="log-level">${entry.level}</span>
|
||
<span class="log-message">${escapeHtml(entry.message)}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
logContainer.scrollTop = logContainer.scrollHeight;
|
||
}
|
||
|
||
function addLogEntry(entry) {
|
||
logEntries.push(entry);
|
||
if (logEntries.length > MAX_LOG_ENTRIES) {
|
||
logEntries.shift();
|
||
}
|
||
renderLogs();
|
||
}
|
||
|
||
function clearLogs() {
|
||
logEntries.length = 0;
|
||
renderLogs();
|
||
}
|
||
|
||
function connectSSE() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
|
||
sseStatusDot.classList.remove('connected');
|
||
sseStatusText.textContent = 'Connecting…';
|
||
|
||
eventSource = new EventSource('/api/logs/stream');
|
||
|
||
eventSource.onopen = () => {
|
||
sseStatusDot.classList.add('connected');
|
||
sseStatusText.textContent = 'Connected';
|
||
};
|
||
|
||
eventSource.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'connected') {
|
||
console.log('SSE connected at', new Date(data.timestamp));
|
||
} else if (data.type === 'log') {
|
||
addLogEntry({
|
||
timestamp: data.timestamp,
|
||
level: data.level,
|
||
message: data.message
|
||
});
|
||
} else if (data.type === 'sync-progress') {
|
||
applySyncProgress(data.progress);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to parse SSE data:', err);
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = () => {
|
||
sseStatusDot.classList.remove('connected');
|
||
sseStatusText.textContent = 'Disconnected — reconnecting…';
|
||
|
||
setTimeout(() => {
|
||
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
|
||
connectSSE();
|
||
}
|
||
}, 3000);
|
||
};
|
||
}
|
||
|
||
connectSSE();
|
||
|
||
// =====================================================
|
||
// SETTINGS MODAL
|
||
// =====================================================
|
||
let currentConfig = null;
|
||
|
||
async function fetchConfig() {
|
||
try {
|
||
const res = await fetch('/api/config');
|
||
currentConfig = await res.json();
|
||
populateSettingsForm();
|
||
renderSubredditEditor();
|
||
} catch (err) {
|
||
console.error('Failed to fetch config:', err);
|
||
}
|
||
}
|
||
|
||
// ---------- Subreddit editor ----------
|
||
function renderSubredditEditor() {
|
||
const container = document.getElementById('subreddit-editor');
|
||
if (!container || !currentConfig?.subreddits) return;
|
||
const tones = currentConfig.tones || ['default'];
|
||
container.innerHTML = '';
|
||
for (const sub of currentConfig.subreddits) {
|
||
container.appendChild(buildSubredditRow(sub, tones, false));
|
||
}
|
||
}
|
||
|
||
function buildSubredditRow(sub, tones, isNew) {
|
||
const row = document.createElement('div');
|
||
row.className = 'subreddit-editor-row' + (isNew ? ' new' : '');
|
||
row.dataset.original = sub.name || '';
|
||
|
||
const nameInput = document.createElement('input');
|
||
nameInput.type = 'text';
|
||
nameInput.value = sub.name || '';
|
||
nameInput.placeholder = 'subreddit name';
|
||
nameInput.dataset.field = 'name';
|
||
if (!isNew) nameInput.readOnly = true;
|
||
row.appendChild(nameInput);
|
||
|
||
const typeSelect = document.createElement('select');
|
||
typeSelect.dataset.field = 'type';
|
||
for (const t of ['image', 'chat']) {
|
||
const opt = document.createElement('option');
|
||
opt.value = t;
|
||
opt.textContent = t;
|
||
if ((sub.type || 'image') === t) opt.selected = true;
|
||
typeSelect.appendChild(opt);
|
||
}
|
||
row.appendChild(typeSelect);
|
||
|
||
const toneSelect = document.createElement('select');
|
||
toneSelect.dataset.field = 'tone';
|
||
for (const t of tones) {
|
||
const opt = document.createElement('option');
|
||
opt.value = t;
|
||
opt.textContent = t;
|
||
if ((sub.tone || 'default') === t) opt.selected = true;
|
||
toneSelect.appendChild(opt);
|
||
}
|
||
row.appendChild(toneSelect);
|
||
|
||
const weightInput = document.createElement('input');
|
||
weightInput.type = 'number';
|
||
weightInput.min = '1';
|
||
weightInput.step = '1';
|
||
weightInput.value = sub.weight ?? '';
|
||
weightInput.placeholder = '1';
|
||
weightInput.dataset.field = 'weight';
|
||
row.appendChild(weightInput);
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.className = 'remove-btn';
|
||
removeBtn.textContent = '×';
|
||
removeBtn.title = 'Remove';
|
||
removeBtn.onclick = () => row.remove();
|
||
row.appendChild(removeBtn);
|
||
|
||
return row;
|
||
}
|
||
|
||
function addSubredditRow() {
|
||
const container = document.getElementById('subreddit-editor');
|
||
if (!container) return;
|
||
const tones = currentConfig?.tones || ['default'];
|
||
container.appendChild(buildSubredditRow({ tone: 'default', type: 'image', weight: 1 }, tones, true));
|
||
}
|
||
|
||
function readSubredditEditor() {
|
||
const rows = document.querySelectorAll('#subreddit-editor .subreddit-editor-row');
|
||
const out = [];
|
||
for (const row of rows) {
|
||
const name = row.querySelector('[data-field="name"]').value.trim().replace(/^\/?r\//i, '');
|
||
if (!name) continue;
|
||
const weightStr = row.querySelector('[data-field="weight"]').value.trim();
|
||
const weight = weightStr === '' ? undefined : Number(weightStr);
|
||
out.push({
|
||
name,
|
||
type: row.querySelector('[data-field="type"]').value,
|
||
tone: row.querySelector('[data-field="tone"]').value,
|
||
...(Number.isFinite(weight) ? { weight } : {}),
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function setSubredditStatus(text, kind) {
|
||
const el = document.getElementById('subreddit-editor-status');
|
||
if (!el) return;
|
||
el.textContent = text || '';
|
||
el.className = 'subreddit-editor-status' + (kind ? ' ' + kind : '');
|
||
}
|
||
|
||
async function saveSubreddits() {
|
||
const btn = document.getElementById('save-subreddits-btn');
|
||
const subreddits = readSubredditEditor();
|
||
if (subreddits.length === 0) {
|
||
setSubredditStatus('At least one subreddit required.', 'error');
|
||
return;
|
||
}
|
||
btn.disabled = true;
|
||
setSubredditStatus('Saving…');
|
||
try {
|
||
const res = await fetch('/api/config', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ subreddits }),
|
||
});
|
||
const result = await res.json();
|
||
if (!res.ok || !result.success) {
|
||
setSubredditStatus('Save failed: ' + (result.error || res.statusText), 'error');
|
||
} else {
|
||
currentConfig.subreddits = result.subreddits;
|
||
renderSubredditEditor();
|
||
setSubredditStatus('Saved.', 'success');
|
||
setTimeout(() => setSubredditStatus(''), 3000);
|
||
fetchStats();
|
||
}
|
||
} catch (err) {
|
||
setSubredditStatus('Save failed: ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function populateSettingsForm() {
|
||
if (!currentConfig) return;
|
||
|
||
document.getElementById('maxMessagesPerHour').value = currentConfig.rateLimit.maxMessagesPerHour;
|
||
document.getElementById('minDelayMinutes').value = Math.round(currentConfig.rateLimit.minDelayBetweenMessagesMs / 60000);
|
||
document.getElementById('jitterSeconds').value = Math.round(currentConfig.rateLimit.jitterMs / 1000);
|
||
document.getElementById('dryRunMode').checked = currentConfig.browser.dryRun;
|
||
if (currentConfig.reddit) {
|
||
document.getElementById('prioritizeRecentPosts').checked = !!currentConfig.reddit.prioritizeRecentPosts;
|
||
document.getElementById('recentWindowMinutes').value = currentConfig.reddit.recentWindowMinutes ?? 60;
|
||
}
|
||
}
|
||
|
||
function openSettingsModal() {
|
||
fetchConfig();
|
||
document.getElementById('settings-modal').classList.add('active');
|
||
}
|
||
|
||
function closeSettingsModal() {
|
||
document.getElementById('settings-modal').classList.remove('active');
|
||
}
|
||
|
||
let loginInfoCache = null;
|
||
|
||
function buildNovncUrl(port) {
|
||
const proto = window.location.protocol === 'https:' ? 'https:' : 'http:';
|
||
return `${proto}//${window.location.hostname}:${port}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
|
||
}
|
||
|
||
async function fetchLoginInfo() {
|
||
if (loginInfoCache) return loginInfoCache;
|
||
try {
|
||
const res = await fetch('/api/login/info');
|
||
if (!res.ok) return null;
|
||
loginInfoCache = await res.json();
|
||
return loginInfoCache;
|
||
} catch (err) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function refreshLoginStatus() {
|
||
const banner = document.getElementById('login-status-banner');
|
||
banner.textContent = 'Checking session status…';
|
||
try {
|
||
const res = await fetch('/api/login/status');
|
||
const data = await res.json();
|
||
if (data.loggedIn) {
|
||
banner.innerHTML = '<span style="color: var(--sage);">✓ Reddit session is active.</span> You can close this window and start the bot.';
|
||
document.getElementById('login-btn').style.display = 'none';
|
||
} else {
|
||
banner.innerHTML = 'Not logged in. Click <strong>Open Reddit login page</strong>, complete the login in the embedded browser, then click <strong>Re-check session</strong>.';
|
||
}
|
||
} catch (err) {
|
||
banner.textContent = 'Could not check session status.';
|
||
}
|
||
}
|
||
|
||
async function openLoginModal() {
|
||
const info = await fetchLoginInfo();
|
||
const wrap = document.getElementById('login-vnc-wrap');
|
||
const missing = document.getElementById('login-vnc-missing');
|
||
const frame = document.getElementById('login-vnc-frame');
|
||
const newTab = document.getElementById('login-vnc-newtab');
|
||
|
||
if (info && info.vncEnabled) {
|
||
const url = buildNovncUrl(info.novncPort);
|
||
if (frame.src !== url) frame.src = url;
|
||
newTab.href = url;
|
||
wrap.style.display = '';
|
||
missing.style.display = 'none';
|
||
} else {
|
||
wrap.style.display = 'none';
|
||
missing.style.display = '';
|
||
}
|
||
|
||
document.getElementById('login-modal').classList.add('active');
|
||
refreshLoginStatus();
|
||
}
|
||
|
||
function closeLoginModal() {
|
||
document.getElementById('login-modal').classList.remove('active');
|
||
const frame = document.getElementById('login-vnc-frame');
|
||
// Disconnect VNC to free the connection.
|
||
frame.src = 'about:blank';
|
||
}
|
||
|
||
async function startLoginFlow() {
|
||
try {
|
||
const res = await fetch('/api/login/start', { method: 'POST' });
|
||
if (!res.ok) {
|
||
alert('Failed to open Reddit login page. Check the server logs.');
|
||
}
|
||
} catch (err) {
|
||
alert('Failed to open Reddit login page: ' + err);
|
||
}
|
||
}
|
||
|
||
async function updateLoginButtonVisibility() {
|
||
try {
|
||
const res = await fetch('/api/login/status');
|
||
const data = await res.json();
|
||
document.getElementById('login-btn').style.display = data.loggedIn ? 'none' : '';
|
||
} catch (err) {
|
||
// If the endpoint isn't reachable yet, leave the button hidden.
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
updateLoginButtonVisibility();
|
||
setInterval(updateLoginButtonVisibility, 30_000);
|
||
});
|
||
|
||
document.getElementById('login-modal').addEventListener('click', function (e) {
|
||
if (e.target === this) closeLoginModal();
|
||
});
|
||
|
||
async function saveSettings(event) {
|
||
event.preventDefault();
|
||
|
||
const newConfig = {
|
||
rateLimit: {
|
||
maxMessagesPerHour: parseInt(document.getElementById('maxMessagesPerHour').value, 10),
|
||
minDelayBetweenMessagesMs: parseInt(document.getElementById('minDelayMinutes').value, 10) * 60000,
|
||
jitterMs: parseInt(document.getElementById('jitterSeconds').value, 10) * 1000
|
||
},
|
||
browser: {
|
||
dryRun: document.getElementById('dryRunMode').checked
|
||
},
|
||
reddit: {
|
||
prioritizeRecentPosts: document.getElementById('prioritizeRecentPosts').checked,
|
||
recentWindowMinutes: parseInt(document.getElementById('recentWindowMinutes').value, 10)
|
||
}
|
||
};
|
||
|
||
try {
|
||
const res = await fetch('/api/config', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(newConfig)
|
||
});
|
||
|
||
const result = await res.json();
|
||
|
||
if (result.success) {
|
||
currentConfig = result.config;
|
||
closeSettingsModal();
|
||
fetchStats();
|
||
addLogEntry({
|
||
timestamp: Date.now(),
|
||
level: 'info',
|
||
message: '✅ Settings saved successfully'
|
||
});
|
||
} else {
|
||
alert('Failed to save settings: ' + (result.error || 'Unknown error'));
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to save settings:', err);
|
||
alert('Failed to save settings: ' + err.message);
|
||
}
|
||
}
|
||
|
||
document.getElementById('settings-modal').addEventListener('click', function (e) {
|
||
if (e.target === this) {
|
||
closeSettingsModal();
|
||
}
|
||
});
|
||
|
||
fetchConfig();
|
||
|
||
/* ───── Page routing (hash-based) ───── */
|
||
function applyPageFromHash() {
|
||
const page = window.location.hash === '#gallery' ? 'gallery' : 'dashboard';
|
||
document.body.dataset.page = page;
|
||
document.querySelectorAll('.page-nav-link').forEach((el) => {
|
||
el.classList.toggle('is-active', el.dataset.nav === page);
|
||
});
|
||
window.scrollTo(0, 0);
|
||
}
|
||
window.addEventListener('hashchange', applyPageFromHash);
|
||
applyPageFromHash();
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|