← back to Animate Museum Posts
src/webDashboard.js
304 lines
import express from 'express';
import helmet from 'helmet';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import { getAllPosts, getUpcomingPosts, deletePost, initDatabase, getPostById, updatePost, addScheduledPost } from './database.js';
import { FAMOUS_PAINTINGS } from './artworks.js';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
const PORT = process.env.DASHBOARD_PORT || 7890;
app.use(express.json());
app.use('/output', express.static(path.join(__dirname, '..', 'output')));
// Dashboard HTML
const dashboardHTML = `
<!DOCTYPE html>
<html>
<head>
<title>Museum Art Bot - Scheduled Posts</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
h1 { text-align: center; margin-bottom: 30px; color: #00d4ff; }
.stats { display: flex; justify-content: center; gap: 30px; margin-bottom: 30px; }
.stat { background: #16213e; padding: 20px 40px; border-radius: 10px; text-align: center; }
.stat-number { font-size: 36px; font-weight: bold; color: #00d4ff; }
.stat-label { color: #888; margin-top: 5px; }
.posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.post { background: #16213e; border-radius: 10px; overflow: hidden; position: relative; }
.post.posted { opacity: 0.6; }
.post.failed { border: 2px solid #ff4444; }
.post video, .post img { width: 100%; height: 180px; object-fit: cover; }
.post-info { padding: 15px; }
.post-title { font-size: 16px; font-weight: bold; margin-bottom: 5px; }
.post-artist { color: #888; font-size: 14px; margin-bottom: 10px; }
.post-time { color: #00d4ff; font-size: 13px; }
.post-status { position: absolute; top: 10px; right: 10px; padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: bold; }
.status-pending { background: #ffd700; color: #000; }
.status-posted { background: #00cc66; color: #fff; }
.status-failed { background: #ff4444; color: #fff; }
.delete-btn { background: #ff4444; color: #fff; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer; margin-top: 10px; width: 100%; }
.delete-btn:hover { background: #cc0000; }
.refresh-btn { position: fixed; bottom: 20px; right: 20px; background: #00d4ff; color: #000; border: none; padding: 15px 30px; border-radius: 30px; cursor: pointer; font-size: 16px; font-weight: bold; }
.tweet-link { color: #00d4ff; text-decoration: none; display: block; margin-top: 10px; }
.countdown { color: #ffd700; font-size: 12px; margin-top: 5px; }
.controls { display: flex; justify-content: center; gap: 15px; margin-bottom: 30px; }
.control-btn { padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold; }
.pause-btn { background: #ffd700; color: #000; }
.resume-btn { background: #00cc66; color: #fff; }
.edit-btn { background: #00d4ff; color: #000; border: none; padding: 6px 12px; border-radius: 5px; cursor: pointer; margin-top: 5px; width: 100%; font-size: 12px; }
.status-badge { padding: 4px 12px; border-radius: 15px; font-size: 12px; margin-left: 10px; }
.status-badge.active { background: #00cc66; color: #fff; }
.status-badge.paused { background: #ffd700; color: #000; }
</style>
</head>
<body>
<h1>🎨 Museum Art Bot - Scheduled Posts <span id="status-badge" class="status-badge active">ACTIVE</span></h1>
<div class="controls">
<button id="pause-btn" class="control-btn pause-btn" onclick="togglePause()">Pause Posting</button>
</div>
<div class="stats">
<div class="stat">
<div class="stat-number" id="pending-count">-</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat">
<div class="stat-number" id="posted-count">-</div>
<div class="stat-label">Posted</div>
</div>
<div class="stat">
<div class="stat-number" id="failed-count">-</div>
<div class="stat-label">Failed</div>
</div>
</div>
<div class="posts" id="posts-container"></div>
<button class="refresh-btn" onclick="loadPosts()">🔄 Refresh</button>
<script>
async function loadPosts() {
const res = await fetch('/api/posts');
const posts = await res.json();
const pending = posts.filter(p => p.status === 'pending').length;
const posted = posts.filter(p => p.status === 'posted').length;
const failed = posts.filter(p => p.status === 'failed').length;
document.getElementById('pending-count').textContent = pending;
document.getElementById('posted-count').textContent = posted;
document.getElementById('failed-count').textContent = failed;
const container = document.getElementById('posts-container');
container.innerHTML = posts.map(post => {
const scheduledTime = new Date(post.scheduled_time);
const now = new Date();
const diff = scheduledTime - now;
const minutes = Math.floor(diff / 60000);
const countdown = diff > 0 ? (minutes > 60 ? Math.floor(minutes/60) + 'h ' + (minutes%60) + 'm' : minutes + ' min') : 'Now';
const videoUrl = post.video_path.replace('/root/Projects/animate-museum-posts/', '/');
const thumbUrl = post.thumbnail_path ? post.thumbnail_path.replace('/root/Projects/animate-museum-posts/', '/') : videoUrl;
return \`
<div class="post \${post.status}">
<video src="\${videoUrl}" muted loop onmouseenter="this.play()" onmouseleave="this.pause()"></video>
<span class="post-status status-\${post.status}">\${post.status.toUpperCase()}</span>
<div class="post-info">
<div class="post-title">\${post.title}</div>
<div class="post-artist">\${post.artist} (\${post.year})</div>
<div class="post-time">📅 \${scheduledTime.toLocaleString()}</div>
\${post.status === 'pending' ? \`<div class="countdown">⏱️ In \${countdown}</div>\` : ''}
\${post.tweet_url ? \`<a href="\${post.tweet_url}" target="_blank" rel="noopener noreferrer" class="tweet-link">View Tweet →</a>\` : ''}
\${post.status === 'pending' ? \`<button class="edit-btn" onclick="reschedulePost(\${post.id})">📅 Reschedule</button>\` : ''}
\${post.status === 'pending' ? \`<button class="delete-btn" onclick="deletePost(\${post.id})">🗑️ Delete</button>\` : ''}
</div>
</div>
\`;
}).join('');
}
async function deletePost(id) {
if (!confirm('Delete this scheduled post?')) return;
await fetch('/api/posts/' + id, { method: 'DELETE' });
loadPosts();
}
let isPaused = false;
async function loadStatus() {
const res = await fetch('/api/status');
const status = await res.json();
isPaused = status.paused;
updatePauseButton();
}
function updatePauseButton() {
const btn = document.getElementById('pause-btn');
const badge = document.getElementById('status-badge');
if (isPaused) {
btn.textContent = 'Resume Posting';
btn.className = 'control-btn resume-btn';
badge.textContent = 'PAUSED';
badge.className = 'status-badge paused';
} else {
btn.textContent = 'Pause Posting';
btn.className = 'control-btn pause-btn';
badge.textContent = 'ACTIVE';
badge.className = 'status-badge active';
}
}
async function togglePause() {
const endpoint = isPaused ? '/api/resume' : '/api/pause';
await fetch(endpoint, { method: 'POST' });
isPaused = !isPaused;
updatePauseButton();
}
async function reschedulePost(id) {
const newTime = prompt('Enter new time (ISO format or minutes from now):', '30');
if (!newTime) return;
let scheduledTime;
if (newTime.includes('T') || newTime.includes('-')) {
scheduledTime = new Date(newTime).toISOString();
} else {
const mins = parseInt(newTime);
scheduledTime = new Date(Date.now() + mins * 60000).toISOString();
}
await fetch('/api/posts/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scheduledTime })
});
loadPosts();
}
loadPosts();
loadStatus();
setInterval(loadPosts, 30000);
setInterval(loadStatus, 30000);
</script>
</body>
</html>
`;
// Routes
app.get('/', (req, res) => {
res.send(dashboardHTML);
});
app.get('/api/posts', async (req, res) => {
try {
const posts = await getAllPosts();
res.json(posts);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/posts/:id', async (req, res) => {
try {
const post = await deletePost(req.params.id);
if (post) {
// Delete the video file
if (fs.existsSync(post.video_path)) {
fs.unlinkSync(post.video_path);
}
if (post.thumbnail_path && fs.existsSync(post.thumbnail_path)) {
fs.unlinkSync(post.thumbnail_path);
}
res.json({ success: true });
} else {
res.status(404).json({ error: 'Post not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/posts/:id', async (req, res) => {
try {
const post = await getPostById(req.params.id);
if (post) {
res.json(post);
} else {
res.status(404).json({ error: 'Post not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Update post (reschedule, edit)
app.put('/api/posts/:id', async (req, res) => {
try {
const { title, artist, scheduledTime, status } = req.body;
const post = await updatePost(req.params.id, { title, artist, scheduledTime, status });
if (post) {
res.json(post);
} else {
res.status(404).json({ error: 'Post not found' });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get artworks list
app.get('/api/artworks', (req, res) => {
res.json(FAMOUS_PAINTINGS.map((art, i) => ({
id: i,
title: art.title,
artist: art.artist,
year: art.year,
museum: art.museum,
imageUrl: art.imageUrl
})));
});
// Pause/resume state
let isPaused = false;
app.get('/api/status', (req, res) => {
res.json({ paused: isPaused });
});
app.post('/api/pause', (req, res) => {
isPaused = true;
res.json({ paused: true });
});
app.post('/api/resume', (req, res) => {
isPaused = false;
res.json({ paused: false });
});
export function startDashboard() {
initDatabase().then(() => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`Dashboard running at http://45.61.58.125:${PORT}`);
});
});
}
// Run if called directly
if (process.argv[1] === fileURLToPath(import.meta.url)) {
startDashboard();
}