← 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();
}