Files
RedditBot/public/index.html
T
jjbruntonandClaude Opus 4.6 dfa0580859 Update source code and remove runtime data from tracking
Add gitignore rules for browser session data, SQLite databases,
media downloads, and build artifacts. Remove previously tracked
runtime files from the repository.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-20 18:54:07 +01:00

2660 lines
74 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 Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #e4e4e4;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #333;
}
h1 {
font-size: 28px;
color: #ff6b6b;
}
.status-badge {
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
}
.status-running {
background: #2ecc71;
color: #fff;
}
.status-dry-run {
background: #f39c12;
color: #fff;
}
.status-stopped {
background: #e74c3c;
color: #fff;
}
.control-btn {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.2s ease;
margin-left: 12px;
}
.control-btn.start {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: #fff;
}
.control-btn.start:hover {
background: linear-gradient(135deg, #27ae60 0%, #229954 100%);
transform: translateY(-1px);
}
.control-btn.stop {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: #fff;
}
.control-btn.stop:hover {
background: linear-gradient(135deg, #c0392b 0%, #a93226 100%);
transform: translateY(-1px);
}
.header-controls {
display: flex;
align-items: center;
gap: 8px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-card h3 {
font-size: 12px;
text-transform: uppercase;
color: #888;
margin-bottom: 8px;
}
.stat-card .value {
font-size: 32px;
font-weight: 700;
color: #fff;
}
.stat-card .subtext {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.stat-card.highlight {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
}
.stat-card.highlight h3,
.stat-card.highlight .subtext {
color: rgba(255, 255, 255, 0.8);
}
.panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) {
.panels {
grid-template-columns: 1fr;
}
}
.panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.panel-header {
padding: 16px 20px;
background: rgba(0, 0, 0, 0.2);
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
color: #aaa;
}
.panel-content {
padding: 0;
max-height: 400px;
overflow-y: auto;
}
.activity-item {
padding: 12px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 12px;
align-items: flex-start;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.activity-icon.sent {
background: #2ecc71;
}
.activity-icon.skipped {
background: #95a5a6;
}
.activity-icon.response {
background: #9b59b6;
}
.activity-icon.error {
background: #e74c3c;
}
.activity-content {
flex: 1;
min-width: 0;
}
.activity-message {
font-size: 14px;
color: #ddd;
word-wrap: break-word;
}
.activity-time {
font-size: 11px;
color: #666;
margin-top: 4px;
}
.activity-thumb {
width: 48px;
height: 48px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: opacity 0.2s;
}
.activity-thumb:hover {
opacity: 0.8;
}
.user-search {
display: flex;
gap: 8px;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.user-search input {
flex: 1;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
outline: none;
}
.user-search input::placeholder {
color: #666;
}
.user-search input:focus {
border-color: rgba(52, 152, 219, 0.6);
}
.user-search button {
background: rgba(52, 152, 219, 0.3);
border: 1px solid rgba(52, 152, 219, 0.4);
color: #7ec8e3;
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
.user-search button:hover {
background: rgba(52, 152, 219, 0.5);
color: #fff;
}
.user-lookup-results {
display: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.user-lookup-results.visible {
display: block;
}
.lookup-post {
padding: 12px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
display: flex;
gap: 12px;
align-items: flex-start;
}
.lookup-post:last-child {
border-bottom: none;
}
.lookup-post-image {
width: 80px;
height: 80px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
}
.lookup-post-image:hover {
opacity: 0.8;
}
.lookup-post-info {
flex: 1;
min-width: 0;
}
.lookup-post-title {
font-size: 14px;
color: #ddd;
font-weight: 500;
margin-bottom: 4px;
word-wrap: break-word;
}
.lookup-post-meta {
font-size: 12px;
color: #888;
margin-bottom: 4px;
}
.lookup-post-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
}
.lookup-post-status.sent {
background: rgba(46, 204, 113, 0.2);
color: #2ecc71;
}
.lookup-post-status.error {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
}
.lookup-post-status.pending {
background: rgba(149, 165, 166, 0.2);
color: #95a5a6;
}
.lookup-close {
background: none;
border: none;
color: #666;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
}
.lookup-close:hover {
color: #fff;
}
.lookup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px 6px;
font-size: 12px;
color: #888;
}
.message-item {
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.message-item:last-child {
border-bottom: none;
}
.message-recipient {
font-weight: 600;
color: #ff6b6b;
font-size: 13px;
margin-bottom: 6px;
}
.message-text {
font-size: 14px;
color: #bbb;
line-height: 1.5;
}
.message-meta {
font-size: 11px;
color: #666;
margin-top: 8px;
display: flex;
gap: 12px;
}
.response-badge {
background: #9b59b6;
color: #fff;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
}
.config-section {
margin-top: 30px;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.config-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.config-item h4 {
font-size: 12px;
color: #888;
text-transform: uppercase;
margin-bottom: 8px;
}
.config-item .value {
color: #fff;
font-size: 14px;
}
.tag {
display: inline-block;
background: rgba(255, 107, 107, 0.2);
color: #ff6b6b;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
margin: 2px;
}
.empty-state {
padding: 40px;
text-align: center;
color: #666;
}
.chat-item {
padding: 14px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 12px;
align-items: flex-start;
}
.chat-item:last-child {
border-bottom: none;
}
.chat-item.unread {
background: rgba(155, 89, 182, 0.1);
}
.chat-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
.chat-content {
flex: 1;
min-width: 0;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.chat-username {
font-weight: 600;
color: #ff6b6b;
font-size: 13px;
}
.chat-time {
font-size: 11px;
color: #666;
}
.chat-snippet {
font-size: 13px;
color: #999;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-direction {
font-size: 10px;
color: #666;
margin-top: 4px;
}
.chat-direction.from-us {
color: #2ecc71;
}
.chat-direction.from-them {
color: #9b59b6;
}
.unread-badge {
background: #9b59b6;
color: #fff;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
margin-left: 8px;
}
.refresh-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #aaa;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
margin-left: auto;
}
.refresh-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.panel-header-row {
display: flex;
align-items: center;
padding: 16px 20px;
background: rgba(0, 0, 0, 0.2);
}
.panel-header-row .panel-title {
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
color: #aaa;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
}
::-webkit-scrollbar-thumb {
background: #444;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Conversation Modal Styles */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
backdrop-filter: blur(4px);
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
width: 90%;
max-width: 700px;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal-header {
padding: 20px 24px;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 18px;
color: #ff6b6b;
margin: 0;
}
.modal-header .close-btn {
background: none;
border: none;
color: #888;
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
transition: color 0.2s ease;
}
.modal-header .close-btn:hover {
color: #fff;
}
.modal-body {
padding: 20px 24px;
overflow-y: auto;
flex: 1;
}
.conversation-meta {
display: flex;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-wrap: wrap;
}
.conversation-meta .meta-item {
font-size: 12px;
color: #888;
}
.conversation-meta .meta-item span {
color: #ddd;
font-weight: 500;
}
.message-bubble {
padding: 12px 16px;
border-radius: 16px;
margin-bottom: 12px;
max-width: 85%;
line-height: 1.5;
font-size: 14px;
}
.message-bubble.sent {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
margin-left: auto;
border-bottom-right-radius: 4px;
}
.message-bubble.received {
background: rgba(255, 255, 255, 0.08);
color: #ddd;
border-bottom-left-radius: 4px;
}
.message-bubble .sender {
font-size: 11px;
font-weight: 600;
margin-bottom: 4px;
opacity: 0.8;
}
.message-bubble.sent .sender {
color: rgba(255, 255, 255, 0.8);
}
.message-bubble.received .sender {
color: #ff6b6b;
}
.no-messages {
text-align: center;
color: #666;
padding: 40px;
}
.conversation-card {
padding: 14px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 12px;
align-items: flex-start;
cursor: pointer;
transition: background 0.2s ease;
}
.conversation-card:hover {
background: rgba(255, 255, 255, 0.05);
}
.conversation-card:last-child {
border-bottom: none;
}
.conversation-card.has-reply {
border-left: 3px solid #2ecc71;
}
.message-count-badge {
background: rgba(155, 89, 182, 0.3);
color: #9b59b6;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
margin-left: 8px;
}
.reply-indicator {
background: rgba(46, 204, 113, 0.2);
color: #2ecc71;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
margin-left: 8px;
}
.pending-indicator {
background: rgba(243, 156, 18, 0.2);
color: #f39c12;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
margin-left: 8px;
animation: pulse-pending 2s ease-in-out infinite;
}
@keyframes pulse-pending {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.upsell-indicator {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
margin-left: 8px;
}
.conversation-card.has-pending {
border-left: 3px solid #f39c12;
}
.conversation-card.has-upsell {
border-left: 3px solid #e74c3c;
opacity: 0.7;
}
.pending-reply-box {
margin-top: 16px;
padding: 14px 16px;
background: rgba(243, 156, 18, 0.1);
border: 1px solid rgba(243, 156, 18, 0.3);
border-radius: 8px;
}
.pending-reply-label {
font-size: 11px;
color: #f39c12;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 8px;
}
.pending-reply-text {
color: #e4e4e4;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
margin-bottom: 12px;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
}
.pending-reply-actions {
display: flex;
gap: 8px;
align-items: center;
}
.approve-btn {
padding: 8px 20px;
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s ease;
}
.approve-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.approve-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.reject-btn {
padding: 8px 20px;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s ease;
}
.reject-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.reject-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.dismiss-btn {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.1);
color: #aaa;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s ease;
}
.dismiss-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #e4e4e4;
}
.upsell-box {
margin-top: 16px;
padding: 14px 16px;
background: rgba(231, 76, 60, 0.1);
border: 1px solid rgba(231, 76, 60, 0.3);
border-radius: 8px;
}
.upsell-label {
font-size: 11px;
color: #e74c3c;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 6px;
}
.upsell-text {
color: #ccc;
font-size: 13px;
margin-bottom: 10px;
}
.pending-action-status {
font-size: 12px;
margin-left: 8px;
}
/* Media Gallery */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
}
.media-card {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 200px;
text-decoration: none;
color: inherit;
}
.media-card img {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
background: #0f1624;
}
.media-meta {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: #888;
}
.media-meta .media-user {
color: #ff6b6b;
font-weight: 600;
}
/* Realtime Log Styles */
.realtime-log {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 8px;
height: 300px;
overflow-y: auto;
padding: 12px;
}
.log-entry {
padding: 4px 8px;
margin-bottom: 2px;
border-radius: 4px;
display: flex;
gap: 8px;
align-items: flex-start;
}
.log-entry .log-time {
color: #6e7681;
flex-shrink: 0;
font-size: 11px;
}
.log-entry .log-level {
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
}
.log-entry.info .log-level {
background: #1f6feb;
color: #fff;
}
.log-entry.warn .log-level {
background: #d29922;
color: #000;
}
.log-entry.error .log-level {
background: #f85149;
color: #fff;
}
.log-entry.debug .log-level {
background: #6e7681;
color: #fff;
}
.log-entry .log-message {
color: #c9d1d9;
word-break: break-word;
flex: 1;
}
.log-entry.error .log-message {
color: #f85149;
}
.log-entry.warn .log-message {
color: #d29922;
}
.log-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
font-size: 12px;
margin-bottom: 12px;
}
.log-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #e74c3c;
animation: pulse 2s infinite;
}
.log-status-dot.connected {
background: #2ecc71;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Settings Modal Styles */
.settings-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #aaa;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
}
.settings-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 12px;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.form-group input[type="number"],
.form-group input[type="text"] {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 12px 16px;
color: #fff;
font-size: 14px;
transition: all 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.form-group .hint {
font-size: 11px;
color: #666;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.toggle-group {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.toggle-group label {
font-size: 14px;
color: #ddd;
}
.toggle {
position: relative;
width: 48px;
height: 26px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #444;
border-radius: 26px;
transition: all 0.3s ease;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: all 0.3s ease;
}
.toggle input:checked+.toggle-slider {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.toggle input:checked+.toggle-slider:before {
transform: translateX(22px);
}
.modal-footer {
padding: 16px 24px;
background: rgba(0, 0, 0, 0.2);
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.suggested-reply {
margin-top: 16px;
padding: 12px 16px;
background: rgba(102, 126, 234, 0.15);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 8px;
}
.suggested-reply-label {
font-size: 11px;
color: #667eea;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 6px;
}
.suggested-reply-text {
color: #e4e4e4;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
}
.suggest-btn {
margin-top: 12px;
padding: 8px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s ease;
}
.suggest-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.suggest-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.reply-editor {
margin-top: 12px;
}
.reply-editor textarea {
width: 100%;
min-height: 80px;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #e4e4e4;
font-size: 14px;
font-family: inherit;
resize: vertical;
}
.reply-editor textarea:focus {
outline: none;
border-color: rgba(102, 126, 234, 0.5);
}
.reply-actions {
display: flex;
gap: 8px;
margin-top: 8px;
align-items: center;
}
.send-btn {
padding: 8px 20px;
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s ease;
}
.send-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.send-status {
font-size: 12px;
margin-left: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #aaa;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.clear-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #888;
padding: 4px 12px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
}
.clear-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Timer Styles */
.timer-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 12px;
position: relative;
overflow: hidden;
}
.timer-card:last-child {
margin-bottom: 0;
}
.timer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.timer-title {
font-size: 13px;
font-weight: 600;
color: #aaa;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.timer-value {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.timer-progress-bg {
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 8px;
}
.timer-progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
width: 0%;
transition: width 1s linear;
}
.timer-progress-fill.warning {
background: linear-gradient(90deg, #f39c12, #e67e22);
}
.timer-progress-fill.danger {
background: linear-gradient(90deg, #e74c3c, #c0392b);
}
.timer-status {
font-size: 11px;
color: #666;
margin-top: 6px;
}
.timer-icon {
font-size: 16px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Reddit Bot Dashboard</h1>
<div class="header-controls">
<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>
<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="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>
<!-- Active Timers Panel -->
<div class="panels" style="margin-bottom: 20px;">
<div class="panel" style="grid-column: span 2;">
<div class="panel-header">⏳ Active Timers</div>
<div class="panel-content" style="padding: 16px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px;">
<!-- Next Message Timer -->
<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>
<!-- Hourly Limit Timer -->
<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>
<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>
<div class="panels" style="margin-top: 20px;">
<div class="panel" style="grid-column: span 2;">
<div class="panel-header-row">
<span class="panel-title">Active Chats (Last 48 Hours)</span>
<button class="refresh-btn" onclick="fetchFromReddit()" id="fetch-reddit-btn"
style="background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%); color: #fff; border: none;">🌐 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: 20px;">
<div class="panel" style="grid-column: span 2;">
<div class="panel-header-row">
<span class="panel-title">📁 Cached Conversations (Browsable)</span>
<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>
<!-- 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()">&times;</button>
</div>
<div class="modal-body" id="modal-body">
<div class="empty-state">Loading...</div>
</div>
</div>
</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>
<!-- Realtime Log Panel -->
<div class="panels" style="margin-top: 20px;">
<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>
<!-- Media Gallery Panel -->
<div class="panels" style="margin-top: 20px;">
<div class="panel" style="grid-column: span 2;">
<div class="panel-header-row">
<span class="panel-title">Media Gallery</span>
<span style="display:flex;gap:8px;">
<button class="refresh-btn" onclick="triggerFullMediaSync()" id="full-media-sync-btn">Sync All Conversations</button>
<button class="refresh-btn" onclick="fetchMedia()" id="refresh-media-btn">Refresh</button>
</span>
</div>
<div class="panel-content media-grid" id="media-grid">
<div class="empty-state">No media cached yet</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div class="modal-overlay" id="settings-modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2>⚙️ Bot Settings</h2>
<button class="close-btn" onclick="closeSettingsModal()">&times;</button>
</div>
<div class="modal-body">
<form class="settings-form" id="settings-form" onsubmit="saveSettings(event)">
<h3 style="color: #ff6b6b; font-size: 14px; margin-bottom: 8px;">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 style="color: #ff6b6b; font-size: 14px; margin: 16px 0 8px;">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>
</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' : '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';
stoppedBadge.style.display = 'none';
controlBtn.textContent = 'Stop Bot';
controlBtn.className = 'control-btn stop';
} else {
runningBadge.style.display = 'none';
stoppedBadge.style.display = 'inline';
controlBtn.textContent = 'Start Bot';
controlBtn.className = 'control-btn start';
}
document.getElementById('config-subreddits').innerHTML = stats.subreddits
.map(s => `<span class="tag">r/${s}</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()">&times;</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()">&times;</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" style="color: #7ec8e3; text-decoration: none;">${escapeHtml(post.title || 'Untitled')}</a></div>
<div class="lookup-post-meta">r/${escapeHtml(post.subreddit)} &middot; ${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 => {
// Construct Reddit URL
// If we have subreddit, make a nice link. If not, fallback to shortlink.
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)
: '';
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="float: right; color: #888; font-size: 11px; text-decoration: none; border: 1px solid #444; padding: 2px 6px; border-radius: 4px;">
${sourceLabel} ↗
</a>
</div>
${postTitle ? `<div style="color: #999; font-size: 11px; margin-bottom: 4px; font-style: italic;">Re: ${postTitle}</div>` : ''}
<div class="message-text">${escapeHtml(msg.message)}</div>
<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) {
// Immediately refresh stats to update UI
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) {
// Refresh both panels with the new data
await fetchChats();
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() {
const container = document.getElementById('chats-list');
const refreshBtn = document.getElementById('refresh-chats-btn');
try {
if (refreshBtn) {
refreshBtn.disabled = true;
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';
return `
<div class="chat-item ${chat.unread ? 'unread' : ''}">
<div class="chat-avatar">${chat.username.charAt(0).toUpperCase()}</div>
<div class="chat-content">
<div class="chat-header">
<a href="${chatUrl}" target="_blank" class="chat-username" style="text-decoration: none; cursor: pointer;">
u/${escapeHtml(chat.username)} ↗
${chat.unread ? '<span class="unread-badge">Unread</span>' : ''}
</a>
<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';
}
}
}
async function triggerFullMediaSync() {
const btn = document.getElementById('full-media-sync-btn');
try {
btn.disabled = true;
btn.textContent = 'Syncing...';
const res = await fetch('/api/media/sync/full', { method: 'POST' });
const result = await res.json();
if (result.success) {
// Poll status until done, then refresh gallery
const poll = setInterval(async () => {
const status = await (await fetch('/api/media/sync')).json();
if (!status.isSyncing) {
clearInterval(poll);
btn.disabled = false;
btn.textContent = 'Sync All Conversations';
await fetchMedia();
}
}, 3000);
} else {
btn.disabled = false;
btn.textContent = 'Sync All Conversations';
alert(result.reason || 'Sync failed');
}
} catch (err) {
console.error('Full media sync failed:', err);
btn.disabled = false;
btn.textContent = 'Sync All Conversations';
}
}
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 || 'Unknown';
const timestamp = item.createdAt || item.timestamp || Date.now();
const mediaUrl = item.urlPath || '';
return `
<a class="media-card" href="${mediaUrl}" target="_blank" rel="noopener">
<img src="${mediaUrl}" alt="Chat media from ${escapeHtml(username)}" loading="lazy" />
<div class="media-meta">
<span class="media-user">u/${escapeHtml(username)}</span>
<span>${formatTime(timestamp)}</span>
</div>
</a>
`;
}).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();
// Update Next Message Timer
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;
// Use configured min delay for scale if possible, otherwise default to 5m
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';
}
// Update Hourly Timer
const hourlyEl = document.getElementById('timer-hourly');
const hourlyProgress = document.getElementById('progress-hourly');
const hourlyDiff = Math.max(0, timerState.hourlyResetAt - now);
hourlyEl.textContent = formatDuration(hourlyDiff);
// Hourly starts at 60 mins.
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();
fetchConversations();
fetchMedia();
// Poll for updates (chats and conversations not polled - use manual refresh)
setInterval(() => {
fetchStats();
fetchTimers();
fetchActivity();
fetchMessages();
fetchSuccessful();
}, 5000);
// Fast interval for smooth timer updates
setInterval(updateTimerUI, 1000);
// Conversation browsing functions
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');
const conversations = await res.json();
if (conversations.length === 0) {
container.innerHTML = '<div class="empty-state">No cached conversations. Click "Fetch All" in Active Chats to populate the cache.</div>';
return;
}
container.innerHTML = conversations.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;
const isPending = conv.replyStatus === 'pending';
const isUpsell = conv.replyStatus === 'upsell_detected';
let cardClass = 'conversation-card';
if (isPending) cardClass += ' has-pending';
else if (isUpsell) cardClass += ' has-upsell';
else if (hasReply) cardClass += ' has-reply';
let statusBadge = '';
if (isPending) statusBadge = '<span class="pending-indicator">Reply Pending Approval</span>';
else if (isUpsell) statusBadge = '<span class="upsell-indicator">Upsell Detected</span>';
else if (hasReply) statusBadge = '<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('');
} 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';
}
}
}
async function openConversation(username) {
const modal = document.getElementById('conversation-modal');
const modalTitle = document.getElementById('modal-title');
const modalBody = document.getElementById('modal-body');
modalTitle.textContent = `Conversation with u/${username}`;
modalBody.innerHTML = '<div class="empty-state">Loading...</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();
// Build meta info
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.originMessage) {
metaHtml += `<div class="meta-item">Origin Msg: <span>${escapeHtml(conv.originMessage.slice(0, 50))}...</span></div>`;
}
metaHtml += '</div>';
// Build messages
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;
return `
<div class="message-bubble ${bubbleClass}">
<div class="sender">${escapeHtml(senderLabel)}</div>
<div class="text">${escapeHtml(msg.text)}</div>
</div>
`;
}).join('');
} else {
messagesHtml = '<div class="no-messages">No messages cached for this conversation. Try fetching all messages from Active Chats.</div>';
}
// Build auto-reply status section
let autoReplyHtml = '';
if (conv.replyStatus === 'pending' && conv.pendingReply) {
autoReplyHtml = `
<div class="pending-reply-box">
<div class="pending-reply-label">Reply Pending Approval</div>
<div class="pending-reply-text">${escapeHtml(conv.pendingReply)}</div>
<div class="pending-reply-actions">
<button class="approve-btn" id="approve-btn" onclick="approveReply('${escapeHtml(conv.username)}')">Approve & Send</button>
<button class="reject-btn" id="reject-btn" onclick="rejectReply('${escapeHtml(conv.username)}')">Reject</button>
<span class="pending-action-status" id="pending-action-status"></span>
</div>
</div>
`;
} else if (conv.replyStatus === 'upsell_detected') {
autoReplyHtml = `
<div class="upsell-box">
<div class="upsell-label">Upsell / Spam Detected</div>
<div class="upsell-text">This conversation was flagged as an upsell attempt (OnlyFans, selling content, etc.). No auto-reply was generated.</div>
<button class="dismiss-btn" onclick="dismissUpsell('${escapeHtml(conv.username)}')">Dismiss</button>
</div>
`;
}
// Add suggest reply button if they've replied and no pending status
const hasReply = conv.messages && conv.messages.some(m => !m.isSelf);
let suggestHtml = '';
if (hasReply && !conv.replyStatus) {
suggestHtml = `
<div id="suggest-reply-container">
<button class="suggest-btn" onclick="suggestReply('${escapeHtml(conv.username)}')">Suggest Next Reply</button>
</div>
`;
}
modalBody.innerHTML = metaHtml + messagesHtml + autoReplyHtml + suggestHtml;
} catch (err) {
console.error('Failed to load conversation:', err);
modalBody.innerHTML = '<div class="empty-state">Failed to load conversation details</div>';
}
}
async function suggestReply(username) {
const container = document.getElementById('suggest-reply-container');
const btn = container.querySelector('.suggest-btn');
btn.disabled = true;
btn.textContent = 'Generating...';
try {
const res = await fetch(`/api/conversations/${encodeURIComponent(username)}/suggest-reply`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to generate reply');
}
const data = await res.json();
container.innerHTML = `
<div class="suggested-reply">
<div class="suggested-reply-label">Suggested Reply</div>
<div class="reply-editor">
<textarea id="reply-text">${escapeHtml(data.suggestedReply)}</textarea>
<div class="reply-actions">
<button class="send-btn" onclick="sendReply('${escapeHtml(username)}')">Send</button>
<button class="suggest-btn" onclick="suggestReply('${escapeHtml(username)}')">Regenerate</button>
<span class="send-status" id="send-status"></span>
</div>
</div>
</div>
`;
} catch (err) {
console.error('Failed to suggest reply:', err);
btn.disabled = false;
btn.textContent = 'Suggest Next Reply';
container.innerHTML += `<div style="color: #e74c3c; font-size: 12px; margin-top: 6px;">${escapeHtml(err.message)}</div>`;
}
}
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 = '#e74c3c';
status.textContent = 'Message cannot be empty';
return;
}
sendBtn.disabled = true;
sendBtn.textContent = 'Sending...';
status.style.color = '#f39c12';
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 = '#2ecc71';
status.textContent = 'Sent!';
sendBtn.textContent = 'Sent';
textarea.disabled = true;
} catch (err) {
console.error('Failed to send reply:', err);
status.style.color = '#e74c3c';
status.textContent = err.message;
sendBtn.disabled = false;
sendBtn.textContent = 'Send';
}
}
async function approveReply(username) {
const approveBtn = document.getElementById('approve-btn');
const rejectBtn = document.getElementById('reject-btn');
const status = document.getElementById('pending-action-status');
approveBtn.disabled = true;
rejectBtn.disabled = true;
approveBtn.textContent = 'Sending...';
status.style.color = '#f39c12';
status.textContent = 'Sending reply...';
try {
const res = await fetch(`/api/conversations/${encodeURIComponent(username)}/approve-reply`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to approve');
}
status.style.color = '#2ecc71';
status.textContent = 'Sent!';
approveBtn.textContent = 'Sent';
// Refresh conversation list after a short delay
setTimeout(() => {
fetchConversations();
openConversation(username);
}, 1500);
} catch (err) {
console.error('Failed to approve reply:', err);
status.style.color = '#e74c3c';
status.textContent = err.message;
approveBtn.disabled = false;
rejectBtn.disabled = false;
approveBtn.textContent = 'Approve & Send';
}
}
async function rejectReply(username) {
const approveBtn = document.getElementById('approve-btn');
const rejectBtn = document.getElementById('reject-btn');
const status = document.getElementById('pending-action-status');
approveBtn.disabled = true;
rejectBtn.disabled = true;
rejectBtn.textContent = 'Rejecting...';
try {
const res = await fetch(`/api/conversations/${encodeURIComponent(username)}/reject-reply`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to reject');
}
status.style.color = '#aaa';
status.textContent = 'Reply discarded';
// Refresh
setTimeout(() => {
fetchConversations();
openConversation(username);
}, 1000);
} catch (err) {
console.error('Failed to reject reply:', err);
status.style.color = '#e74c3c';
status.textContent = err.message;
approveBtn.disabled = false;
rejectBtn.disabled = false;
rejectBtn.textContent = 'Reject';
}
}
async function dismissUpsell(username) {
try {
const res = await fetch(`/api/conversations/${encodeURIComponent(username)}/dismiss-upsell`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to dismiss');
fetchConversations();
openConversation(username);
} catch (err) {
console.error('Failed to dismiss upsell:', err);
}
}
function closeConversationModal() {
document.getElementById('conversation-modal').classList.remove('active');
}
// Close modal on overlay click
document.getElementById('conversation-modal').addEventListener('click', function (e) {
if (e.target === this) {
closeConversationModal();
}
});
// Close modal on Escape key
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('');
// Auto-scroll to bottom
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
});
}
} catch (err) {
console.error('Failed to parse SSE data:', err);
}
};
eventSource.onerror = () => {
sseStatusDot.classList.remove('connected');
sseStatusText.textContent = 'Disconnected - Reconnecting...';
// Auto-reconnect after 3 seconds
setTimeout(() => {
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
connectSSE();
}
}, 3000);
};
}
// Start SSE connection
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;
}
function openSettingsModal() {
fetchConfig();
document.getElementById('settings-modal').classList.add('active');
}
function closeSettingsModal() {
document.getElementById('settings-modal').classList.remove('active');
}
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
}
};
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();
// Refresh stats to show updated values
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);
}
}
// Close settings modal on overlay click
document.getElementById('settings-modal').addEventListener('click', function (e) {
if (e.target === this) {
closeSettingsModal();
}
});
// Fetch initial config
fetchConfig();
</script>
</body>
</html>