- Blur NSFW media gallery images until hover/reveal - Surface chat sync progress in the dashboard header - Extend chat service, cache, and monitor with delta-media handling - Add tests for chat cooldown, delta media, and monitor flows Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3812 lines
115 KiB
HTML
3812 lines
115 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);
|
||
}
|
||
|
||
.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-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>
|
||
</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">
|
||
<h4>Monitoring subreddits</h4>
|
||
<div class="value" id="config-subreddits"></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-subreddits').innerHTML = stats.subreddits
|
||
.map(s => `<span class="tag${s.type === 'chat' ? ' tag-chat' : ''}" title="${s.type}">r/${s.name}</span>`).join('');
|
||
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 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(msg.recipient)}
|
||
${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;
|
||
}
|
||
|
||
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 safeUsername = escapeHtml(chat.username);
|
||
return `
|
||
<div class="chat-item ${chat.unread ? 'unread' : ''}" style="cursor: pointer;" onclick="openConversation('${safeUsername}')">
|
||
<div class="chat-avatar">${chat.username.charAt(0).toUpperCase()}</div>
|
||
<div class="chat-content">
|
||
<div class="chat-header">
|
||
<span class="chat-username">
|
||
u/${safeUsername}
|
||
<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 = item.chatUsername || item.sender || null;
|
||
const timestamp = item.createdAt || item.timestamp || Date.now();
|
||
const mediaUrl = item.urlPath || '';
|
||
const userLabel = username ? 'u/' + escapeHtml(username) : 'unknown sender';
|
||
// Open the conversation modal if we can identify the user; the
|
||
// image itself stays click-through to a new tab.
|
||
const userClick = username
|
||
? `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="${username ? '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';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 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">${conv.username.charAt(0).toUpperCase()}</div>
|
||
<div class="chat-content">
|
||
<div class="chat-header">
|
||
<span class="chat-username">
|
||
u/${escapeHtml(conv.username)}
|
||
<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/${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(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' : 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();
|
||
} catch (err) {
|
||
console.error('Failed to fetch config:', err);
|
||
}
|
||
}
|
||
|
||
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>
|