No lines found in this episode.
+diff --git a/webnode/README.md b/webnode/README.md new file mode 100644 index 0000000..652943f --- /dev/null +++ b/webnode/README.md @@ -0,0 +1,47 @@ +# Episode Web Viewer (Node.js) + +A simple Express.js web application to view translated episodes with speaker color coding. + +## Features + +Same as the Python/Flask version: +- Mobile-first design +- Vertical line display with selectable lines +- Translation toggle (Chinese shown in expanded card) +- Speaker colors from `_colors.json` +- Episode navigation via side menu +- Server-side rendering with EJS templates + +## Running the App + +```bash +# From webnode folder +./run.sh + +# Or manually: +npm install +npm start + +# Development mode (auto-reload on Node 18+): +npm run dev +``` + +Then open http://localhost:5000 + +## Project Structure + +``` +webnode/ +├── server.js # Express server +├── package.json # Node dependencies +├── run.sh # Run script +├── views/ +│ └── episode.ejs # EJS template +└── public/ + └── style.css # Styles +``` + +## Dependencies + +- **express**: Web framework +- **ejs**: Templating engine diff --git a/webnode/package.json b/webnode/package.json new file mode 100644 index 0000000..a168929 --- /dev/null +++ b/webnode/package.json @@ -0,0 +1,14 @@ +{ + "name": "episode-web-viewer", + "version": "1.0.0", + "description": "Simple web viewer for translated episodes", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^4.18.2", + "ejs": "^3.1.9" + } +} diff --git a/webnode/public/style.css b/webnode/public/style.css new file mode 100644 index 0000000..9002236 --- /dev/null +++ b/webnode/public/style.css @@ -0,0 +1,293 @@ +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + background: #fff; + color: #333; + overflow-x: hidden; +} + +/* Navbar */ +.navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 56px; + background: #fff; + border-bottom: 1px solid #e0e0e0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + z-index: 1000; +} + +.nav-btn { + width: 40px; + height: 40px; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + transition: background 0.2s; +} + +.nav-btn:hover { + background: #f5f5f5; +} + +.nav-btn:active { + background: #e0e0e0; +} + +.nav-btn svg { + width: 24px; + height: 24px; + stroke: #555; +} + +.nav-title { + font-size: 18px; + font-weight: 600; + color: #333; +} + +/* Main content area */ +.main-content { + padding-top: 56px; + min-height: 100vh; +} + +/* Lines container */ +.lines-container { + padding: 8px 0; +} + +/* Individual line */ +.line { + padding: 10px 16px; + cursor: pointer; + transition: background 0.15s; + border-bottom: 1px solid #f0f0f0; +} + +.line:hover { + background: #f9f9f9; +} + +.line.selected { + background: #e3f2fd; +} + +.english { + font-size: 16px; + line-height: 1.5; + color: inherit; +} + +/* Translation card (expanded state) */ +.translation-card { + margin-top: 10px; + padding: 12px 16px; + background: #fff9e6; + border-radius: 8px; + border-left: 3px solid #ffc107; + animation: slideDown 0.2s ease-out; +} + +.meta-info { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid #ffe0b2; +} + +.meta-timestamp { + font-size: 11px; + color: #999; + font-family: monospace; +} + +.meta-speaker { + font-size: 11px; + font-weight: 600; + color: #666; +} + +.translation-label { + font-size: 10px; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.chinese { + font-size: 17px; + line-height: 1.6; + color: #333; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Side menu */ +.side-menu-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; + z-index: 2000; +} + +.side-menu-overlay.active { + opacity: 1; + visibility: visible; +} + +.side-menu { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 280px; + max-width: 80vw; + background: #fff; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); + transform: translateX(100%); + transition: transform 0.3s ease-out; + z-index: 2001; + display: flex; + flex-direction: column; +} + +.side-menu.active { + transform: translateX(0); +} + +.side-menu-header { + height: 56px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #e0e0e0; +} + +.side-menu-title { + font-size: 18px; + font-weight: 600; +} + +.close-btn { + width: 40px; + height: 40px; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; +} + +.close-btn:hover { + background: #f5f5f5; +} + +.close-btn svg { + width: 24px; + height: 24px; + stroke: #555; +} + +.episode-list { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.episode-item { + display: block; + padding: 16px; + text-decoration: none; + color: #333; + border-bottom: 1px solid #f0f0f0; + transition: background 0.15s; +} + +.episode-item:hover { + background: #f5f5f5; +} + +.episode-item.active { + background: #e3f2fd; + font-weight: 500; +} + +.episode-id { + font-size: 16px; +} + +/* Empty state */ +.empty-state { + padding: 40px 16px; + text-align: center; + color: #999; +} + +/* Mobile optimizations */ +@media (max-width: 480px) { + .line { + padding: 12px 16px; + } + + .english { + font-size: 17px; + } + + .chinese { + font-size: 18px; + } +} + +/* Hide scrollbar but keep functionality */ +.episode-list::-webkit-scrollbar { + width: 6px; +} + +.episode-list::-webkit-scrollbar-track { + background: transparent; +} + +.episode-list::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 3px; +} diff --git a/webnode/run.sh b/webnode/run.sh new file mode 100755 index 0000000..29486a1 --- /dev/null +++ b/webnode/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Run the Node.js web server for the episode viewer + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Install dependencies if needed +if [ ! -d "node_modules" ]; then + echo "Installing dependencies..." + npm install +fi + +echo "Starting Episode Web Viewer (Node.js)..." +echo "Open http://localhost:5000 in your browser" +echo "Press Ctrl+C to stop" +echo "" + +npm start diff --git a/webnode/server.js b/webnode/server.js new file mode 100644 index 0000000..43ca6c8 --- /dev/null +++ b/webnode/server.js @@ -0,0 +1,144 @@ +/** + * Simple Express server to display translated episodes with speaker colors. + */ + +const express = require('express'); +const path = require('path'); +const fs = require('fs'); + +const app = express(); +const PORT = process.env.PORT || 5000; + +// Configuration +const TRANSLATED_DIR = path.join(__dirname, '..', '_translated'); +const COLORS_FILE = path.join(__dirname, '..', '_colors.json'); + +// Set up EJS as templating engine +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +// Serve static files +app.use(express.static(path.join(__dirname, 'public'))); + +/** + * Load speaker color mapping. + */ +function loadColors() { + try { + if (fs.existsSync(COLORS_FILE)) { + const data = fs.readFileSync(COLORS_FILE, 'utf-8'); + return JSON.parse(data); + } + } catch (err) { + console.error('Error loading colors:', err); + } + return {}; +} + +/** + * Get list of all episodes from _translated folder. + */ +function getEpisodes() { + const episodes = []; + try { + if (fs.existsSync(TRANSLATED_DIR)) { + const files = fs.readdirSync(TRANSLATED_DIR); + files + .filter(f => f.endsWith('_translated.json')) + .sort() + .forEach(file => { + // Extract episode name from filename (e.g., "S02E01_translated.json" -> "S02E01") + const episodeId = file.replace('_translated.json', ''); + episodes.push({ + id: episodeId, + filename: file, + title: episodeId + }); + }); + } + } catch (err) { + console.error('Error reading episodes:', err); + } + return episodes; +} + +/** + * Load a specific episode's data. + */ +function loadEpisode(episodeId) { + const jsonFile = path.join(TRANSLATED_DIR, `${episodeId}_translated.json`); + try { + if (fs.existsSync(jsonFile)) { + const data = fs.readFileSync(jsonFile, 'utf-8'); + return JSON.parse(data); + } + } catch (err) { + console.error('Error loading episode:', err); + } + return null; +} + +/** + * Escape HTML special characters. + */ +function escapeHtml(text) { + if (!text) return ''; + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Make escapeHtml available to templates +app.locals.escapeHtml = escapeHtml; + +// Routes +app.get('/', (req, res) => { + const episodes = getEpisodes(); + if (episodes.length === 0) { + return res.status(404).send('No episodes found'); + } + + // Get first episode data + const firstEpisode = episodes[0]; + const lines = loadEpisode(firstEpisode.id); + const colors = loadColors(); + + res.render('episode', { + episodes, + currentEpisode: firstEpisode, + lines, + colors + }); +}); + +app.get('/episode/:episodeId', (req, res) => { + const episodeId = req.params.episodeId; + const lines = loadEpisode(episodeId); + + if (!lines) { + return res.status(404).send('Episode not found'); + } + + const episodes = getEpisodes(); + const currentEpisode = { + id: episodeId, + filename: `${episodeId}_translated.json`, + title: episodeId + }; + const colors = loadColors(); + + res.render('episode', { + episodes, + currentEpisode, + lines, + colors + }); +}); + +// Start server +app.listen(PORT, '0.0.0.0', () => { + console.log(`Episode Web Viewer running at http://localhost:${PORT}`); +}); diff --git a/webnode/views/episode.ejs b/webnode/views/episode.ejs new file mode 100644 index 0000000..7a0c494 --- /dev/null +++ b/webnode/views/episode.ejs @@ -0,0 +1,185 @@ + + +
+ + +No lines found in this episode.
+