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>
2660 lines
74 KiB
HTML
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()">×</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()">×</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()">×</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" style="color: #7ec8e3; text-decoration: none;">${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 => {
|
|
// 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>
|