- Add dashboard page with quick actions and progress overview - Add settings page for Qwen TTS, Reddit scraper, and video configuration - Add backgrounds page for uploading custom video/audio backgrounds - Add videos page for viewing and downloading generated content - Add TTS connection test endpoint - Update docker-compose for standalone operation - Create unified CSS and JavaScript for consistent UI experience https://claude.ai/code/session_01HLLH3WjpmRzvaoY6eYSFADpull/2456/head
parent
cd9f9f5b40
commit
2c4a8a8a64
@ -1,263 +1,210 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block main %}
|
||||
|
||||
<!-- Delete Background Modal -->
|
||||
<div class="modal fade" id="deleteBtnModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete background</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Are you sure you want to delete this background?
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
<form action="background/delete" method="post">
|
||||
<input type="hidden" id="background-key" name="background-key" value="">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Backgrounds - Reddit Video Maker Bot</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
<div class="logo">
|
||||
<h2>Reddit Video Bot</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Background Modal -->
|
||||
<div class="modal fade" id="backgroundAddModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add background video</h5>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><a href="/backgrounds" class="active">Backgrounds</a></li>
|
||||
<li><a href="/videos">Videos</a></li>
|
||||
<li><a href="/progress">Progress</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<header>
|
||||
<h1>Backgrounds</h1>
|
||||
</header>
|
||||
|
||||
<!-- Video Backgrounds -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Background Videos</h3>
|
||||
<label for="video-upload" class="btn btn-primary">
|
||||
Upload Video
|
||||
<input type="file" id="video-upload" accept=".mp4,.webm,.mov,.avi,.mkv" hidden>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="card-description">Upload 16:9 landscape videos. They will be cropped/scaled to fit 9:16 portrait videos.</p>
|
||||
|
||||
<!-- Add video form -->
|
||||
<form id="addBgForm" action="background/add" method="post" novalidate>
|
||||
<div class="form-group row">
|
||||
<label class="col-4 col-form-label" for="youtube_uri">YouTube URI</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-youtube"></i>
|
||||
</div>
|
||||
<input name="youtube_uri" placeholder="https://www.youtube.com/watch?v=..." type="text"
|
||||
class="form-control">
|
||||
</div>
|
||||
<span id="feedbackYT" class="form-text feedback-invalid"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="filename" class="col-4 col-form-label">Filename</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-file-earmark"></i>
|
||||
</div>
|
||||
<input name="filename" placeholder="Example: cool-background" type="text"
|
||||
class="form-control">
|
||||
</div>
|
||||
<span id="feedbackFilename" class="form-text feedback-invalid"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="citation" class="col-4 col-form-label">Credits (owner of the video)</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</div>
|
||||
<input name="citation" placeholder="YouTube Channel" type="text" class="form-control">
|
||||
</div>
|
||||
<span class="form-text text-muted">Include the channel name of the
|
||||
owner of the background video you are adding.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="position" class="col-4 col-form-label">Position of screenshots</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-arrows-fullscreen"></i>
|
||||
</div>
|
||||
<input name="position" placeholder="Example: center" type="text" class="form-control">
|
||||
</div>
|
||||
<span class="form-text text-muted">Advanced option (you can leave it
|
||||
empty). Valid options are "center" and decimal numbers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
<button name="submit" type="submit" class="btn btn-success">Add background</button>
|
||||
</form>
|
||||
<div id="video-list" class="media-grid">
|
||||
<div class="loading">Loading videos...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="album py-2 bg-light">
|
||||
<div class="container">
|
||||
|
||||
<div class="row justify-content-between mt-2">
|
||||
<div class="col-12 col-md-3 mb-3">
|
||||
<input type="text" class="form-control searchFilter" placeholder="Search backgrounds"
|
||||
onkeyup="searchFilter()">
|
||||
</div>
|
||||
<div class="col-12 col-md-2 mb-3">
|
||||
<button type="button" class="btn btn-primary form-control" data-toggle="modal"
|
||||
data-target="#backgroundAddModal">
|
||||
Add background video
|
||||
</button>
|
||||
</div>
|
||||
<!-- Audio Backgrounds -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Background Audio</h3>
|
||||
<label for="audio-upload" class="btn btn-primary">
|
||||
Upload Audio
|
||||
<input type="file" id="audio-upload" accept=".mp3,.wav,.ogg,.m4a" hidden>
|
||||
</label>
|
||||
</div>
|
||||
<p class="card-description">Upload background music tracks. Audio will loop to match video length.</p>
|
||||
|
||||
<div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="backgrounds">
|
||||
<div id="audio-list" class="media-grid">
|
||||
<div class="loading">Loading audio...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<div id="upload-progress" class="upload-progress hidden">
|
||||
<div class="upload-progress-bar">
|
||||
<div class="upload-progress-fill"></div>
|
||||
</div>
|
||||
<span class="upload-progress-text">Uploading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var keys = [];
|
||||
var youtube_urls = [];
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadVideos();
|
||||
loadAudios();
|
||||
|
||||
// Show background videos
|
||||
$(document).ready(function () {
|
||||
$.getJSON("backgrounds.json",
|
||||
function (data) {
|
||||
delete data["__comment"];
|
||||
var background = '';
|
||||
$.each(data, function (key, value) {
|
||||
// Add YT urls and keys (for validation)
|
||||
keys.push(key);
|
||||
youtube_urls.push(value[0]);
|
||||
document.getElementById('video-upload').addEventListener('change', (e) => uploadFile(e, 'video'));
|
||||
document.getElementById('audio-upload').addEventListener('change', (e) => uploadFile(e, 'audio'));
|
||||
});
|
||||
|
||||
background += '<div class="col">';
|
||||
background += '<div class="card shadow-sm">';
|
||||
background += '<iframe class="bd-placeholder-img card-img-top" width="100%" height="225" src="https://www.youtube-nocookie.com/embed/' + value[0].split("?v=")[1] + '" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
|
||||
background += '<div class="card-body">';
|
||||
background += '<p class="card-text">' + value[2] + ' • ' + key + '</p>';
|
||||
background += '<div class="d-flex justify-content-between align-items-center">';
|
||||
background += '<div class="btn-group">';
|
||||
background += '<button type="button" class="btn btn-outline-danger" data-toggle="modal" data-target="#deleteBtnModal" data-background-key="' + key + '">Delete</button>';
|
||||
background += '</div>';
|
||||
background += '</div>';
|
||||
background += '</div>';
|
||||
background += '</div>';
|
||||
background += '</div>';
|
||||
function loadVideos() {
|
||||
fetch('/api/backgrounds/video')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const container = document.getElementById('video-list');
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
container.innerHTML = data.videos.map(v => `
|
||||
<div class="media-item">
|
||||
<div class="media-preview video-preview">
|
||||
<video src="/backgrounds/video/${v.filename}" muted loop onmouseenter="this.play()" onmouseleave="this.pause(); this.currentTime=0;"></video>
|
||||
</div>
|
||||
<div class="media-info">
|
||||
<span class="media-name">${v.name}</span>
|
||||
<span class="media-size">${formatFileSize(v.size)}</span>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteVideo('${v.filename}')">Delete</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>No custom background videos</p>
|
||||
<p class="hint">Upload a 16:9 video to use as background</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('video-list').innerHTML = '<div class="error">Error loading videos</div>';
|
||||
});
|
||||
|
||||
$('#backgrounds').append(background);
|
||||
});
|
||||
});
|
||||
|
||||
// Add background key when deleting
|
||||
$('#deleteBtnModal').on('show.bs.modal', function (event) {
|
||||
var button = $(event.relatedTarget);
|
||||
var key = button.data('background-key');
|
||||
|
||||
$('#background-key').prop('value', key);
|
||||
});
|
||||
|
||||
var searchFilter = () => {
|
||||
const input = document.querySelector(".searchFilter");
|
||||
const cards = document.getElementsByClassName("col");
|
||||
console.log(cards[1])
|
||||
let filter = input.value
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
let title = cards[i].querySelector(".card-text");
|
||||
if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
|
||||
cards[i].classList.remove("d-none")
|
||||
} else {
|
||||
cards[i].classList.add("d-none")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate form
|
||||
$("#addBgForm").submit(function (event) {
|
||||
$("#addBgForm input").each(function () {
|
||||
if (!(validate($(this)))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#addBgForm input[type="text"]').on("keyup", function () {
|
||||
validate($(this));
|
||||
});
|
||||
|
||||
function validate(object) {
|
||||
let bool = check(object.prop("name"), object.prop("value"));
|
||||
|
||||
// Change class
|
||||
if (bool) {
|
||||
object.removeClass("is-invalid");
|
||||
object.addClass("is-valid");
|
||||
}
|
||||
else {
|
||||
object.removeClass("is-valid");
|
||||
object.addClass("is-invalid");
|
||||
function loadAudios() {
|
||||
fetch('/api/backgrounds/audio')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const container = document.getElementById('audio-list');
|
||||
if (data.audios && data.audios.length > 0) {
|
||||
container.innerHTML = data.audios.map(a => `
|
||||
<div class="media-item">
|
||||
<div class="media-preview audio-preview">
|
||||
<audio src="/backgrounds/audio/${a.filename}" controls></audio>
|
||||
</div>
|
||||
<div class="media-info">
|
||||
<span class="media-name">${a.name}</span>
|
||||
<span class="media-size">${formatFileSize(a.size)}</span>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteAudio('${a.filename}')">Delete</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>No custom background audio</p>
|
||||
<p class="hint">Upload audio files to use as background music</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('audio-list').innerHTML = '<div class="error">Error loading audio</div>';
|
||||
});
|
||||
}
|
||||
|
||||
return bool;
|
||||
|
||||
// Check values (return true/false)
|
||||
function check(name, value) {
|
||||
if (name == "youtube_uri") {
|
||||
// URI validation
|
||||
let regex = /(?:\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)/;
|
||||
if (!(regex.test(value))) {
|
||||
$("#feedbackYT").html("Invalid URI");
|
||||
$("#feedbackYT").show();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this background already exists
|
||||
if (youtube_urls.includes(value)) {
|
||||
$("#feedbackYT").html("This background is already added");
|
||||
$("#feedbackYT").show();
|
||||
return false;
|
||||
}
|
||||
|
||||
$("#feedbackYT").hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (name == "filename") {
|
||||
// Check if key is already taken
|
||||
if (keys.includes(value)) {
|
||||
$("#feedbackFilename").html("This filename is already taken");
|
||||
$("#feedbackFilename").show();
|
||||
return false;
|
||||
function uploadFile(event, type) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const progressEl = document.getElementById('upload-progress');
|
||||
progressEl.classList.remove('hidden');
|
||||
|
||||
fetch(`/api/backgrounds/${type}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
progressEl.classList.add('hidden');
|
||||
if (data.success) {
|
||||
showNotification(data.message, 'success');
|
||||
if (type === 'video') loadVideos();
|
||||
else loadAudios();
|
||||
} else {
|
||||
showNotification(data.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
progressEl.classList.add('hidden');
|
||||
showNotification('Upload failed', 'error');
|
||||
});
|
||||
|
||||
let regex = /^([a-zA-Z0-9\s_-]{1,100})$/;
|
||||
if (!(regex.test(value))) {
|
||||
return false;
|
||||
}
|
||||
// Reset file input
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
function deleteVideo(filename) {
|
||||
if (!confirm(`Delete video "${filename}"?`)) return;
|
||||
|
||||
if (name == "citation") {
|
||||
if (value.trim()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
fetch(`/api/backgrounds/video/${filename}`, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showNotification(data.message, data.success ? 'success' : 'error');
|
||||
if (data.success) loadVideos();
|
||||
})
|
||||
.catch(err => showNotification('Delete failed', 'error'));
|
||||
}
|
||||
|
||||
if (name == "position") {
|
||||
if (!(value == "center" || value.length == 0 || value % 1 == 0)) {
|
||||
return false;
|
||||
}
|
||||
function deleteAudio(filename) {
|
||||
if (!confirm(`Delete audio "${filename}"?`)) return;
|
||||
|
||||
return true;
|
||||
}
|
||||
fetch(`/api/backgrounds/audio/${filename}`, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showNotification(data.message, data.success ? 'success' : 'error');
|
||||
if (data.success) loadAudios();
|
||||
})
|
||||
.catch(err => showNotification('Delete failed', 'error'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reddit Video Maker Bot</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.6.0/socket.io.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
<div class="logo">
|
||||
<h2>Reddit Video Bot</h2>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/" class="active">Dashboard</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><a href="/backgrounds">Backgrounds</a></li>
|
||||
<li><a href="/videos">Videos</a></li>
|
||||
<li><a href="/progress">Progress</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<header>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="status-indicator" id="status-indicator">
|
||||
<span class="dot"></span>
|
||||
<span id="status-text">Ready</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Quick Actions -->
|
||||
<div class="card">
|
||||
<h3>Quick Actions</h3>
|
||||
<div class="action-buttons">
|
||||
<button id="btn-generate" class="btn btn-primary btn-large">
|
||||
Generate Video
|
||||
</button>
|
||||
<button id="btn-stop" class="btn btn-danger btn-large" disabled>
|
||||
Stop Generation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Config -->
|
||||
<div class="card">
|
||||
<h3>Current Configuration</h3>
|
||||
<div class="config-summary" id="config-summary">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<a href="/settings" class="btn btn-secondary">Edit Settings</a>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="card card-wide">
|
||||
<h3>Current Progress</h3>
|
||||
<div id="progress-container">
|
||||
<div class="no-progress">
|
||||
<p>No video generation in progress</p>
|
||||
<p class="hint">Click "Generate Video" to start</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Videos -->
|
||||
<div class="card card-wide">
|
||||
<h3>Recent Videos</h3>
|
||||
<div id="recent-videos">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<a href="/videos" class="btn btn-secondary">View All Videos</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script>
|
||||
// Initialize dashboard
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadConfig();
|
||||
loadRecentVideos();
|
||||
initProgressSocket();
|
||||
|
||||
document.getElementById('btn-generate').addEventListener('click', startGeneration);
|
||||
document.getElementById('btn-stop').addEventListener('click', stopGeneration);
|
||||
});
|
||||
|
||||
function loadConfig() {
|
||||
fetch('/api/config')
|
||||
.then(r => r.json())
|
||||
.then(config => {
|
||||
const summary = document.getElementById('config-summary');
|
||||
if (config && config.reddit) {
|
||||
summary.innerHTML = `
|
||||
<div class="config-item">
|
||||
<strong>Subreddit:</strong> r/${config.reddit?.thread?.subreddit || 'Not set'}
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>TTS:</strong> ${config.settings?.tts?.voice_choice || 'Not set'}
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>Theme:</strong> ${config.settings?.theme || 'dark'}
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>Resolution:</strong> ${config.settings?.resolution_w || 1080}x${config.settings?.resolution_h || 1920}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
summary.innerHTML = '<p class="warning">Not configured. <a href="/settings">Click here to configure.</a></p>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadRecentVideos() {
|
||||
fetch('/api/videos')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const container = document.getElementById('recent-videos');
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
const recent = data.videos.slice(0, 3);
|
||||
container.innerHTML = recent.map(v => `
|
||||
<div class="video-item">
|
||||
<span class="video-name">${v.name}</span>
|
||||
<span class="video-subreddit">r/${v.subreddit}</span>
|
||||
<a href="${v.path}" target="_blank" class="btn btn-small">View</a>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
container.innerHTML = '<p class="hint">No videos generated yet</p>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initProgressSocket() {
|
||||
const socket = io('/progress');
|
||||
socket.on('progress_update', (data) => {
|
||||
updateProgress(data);
|
||||
updateGenerationStatus(data);
|
||||
});
|
||||
}
|
||||
|
||||
function updateProgress(data) {
|
||||
const container = document.getElementById('progress-container');
|
||||
if (data.current_job) {
|
||||
const job = data.current_job;
|
||||
container.innerHTML = `
|
||||
<div class="progress-info">
|
||||
<div class="progress-title">${job.title}</div>
|
||||
<div class="progress-subreddit">r/${job.subreddit}</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" style="width: ${job.overall_progress}%"></div>
|
||||
</div>
|
||||
<div class="progress-percent">${Math.round(job.overall_progress)}%</div>
|
||||
<div class="progress-steps">
|
||||
${job.steps.map(s => `
|
||||
<div class="step ${s.status}">
|
||||
<span class="step-icon">${getStepIcon(s.status)}</span>
|
||||
<span class="step-name">${s.name}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="no-progress">
|
||||
<p>No video generation in progress</p>
|
||||
<p class="hint">Click "Generate Video" to start</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateGenerationStatus(data) {
|
||||
const btnGenerate = document.getElementById('btn-generate');
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
const statusIndicator = document.getElementById('status-indicator');
|
||||
const statusText = document.getElementById('status-text');
|
||||
|
||||
if (data.current_job && data.current_job.status === 'in_progress') {
|
||||
btnGenerate.disabled = true;
|
||||
btnStop.disabled = false;
|
||||
statusIndicator.classList.add('running');
|
||||
statusText.textContent = 'Generating...';
|
||||
} else {
|
||||
btnGenerate.disabled = false;
|
||||
btnStop.disabled = true;
|
||||
statusIndicator.classList.remove('running');
|
||||
statusText.textContent = 'Ready';
|
||||
}
|
||||
}
|
||||
|
||||
function getStepIcon(status) {
|
||||
switch(status) {
|
||||
case 'completed': return '✓';
|
||||
case 'in_progress': return '◐';
|
||||
case 'failed': return '✗';
|
||||
default: return '○';
|
||||
}
|
||||
}
|
||||
|
||||
function startGeneration() {
|
||||
fetch('/api/generate', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showNotification('Video generation started!', 'success');
|
||||
} else {
|
||||
showNotification(data.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
fetch('/api/generate/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showNotification(data.message, data.success ? 'success' : 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showNotification(message, type) {
|
||||
const notif = document.createElement('div');
|
||||
notif.className = `notification ${type}`;
|
||||
notif.textContent = message;
|
||||
document.body.appendChild(notif);
|
||||
setTimeout(() => notif.remove(), 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,621 +1,390 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block main %}
|
||||
|
||||
<main>
|
||||
<br>
|
||||
<div class="container">
|
||||
<form id="settingsForm" action="/settings" method="post" novalidate>
|
||||
|
||||
<!-- Reddit Credentials -->
|
||||
<p class="h4">Reddit Credentials</p>
|
||||
<div class="row mb-2">
|
||||
<label for="client_id" class="col-4">Client ID</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-person"></i>
|
||||
</div>
|
||||
<input name="client_id" value="{{ data.client_id }}" placeholder="Your Reddit app's client ID"
|
||||
type="text" class="form-control" data-toggle="tooltip"
|
||||
data-original-title='Text under "personal use script" on www.reddit.com/prefs/apps'>
|
||||
</div>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Settings - Reddit Video Maker Bot</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
<div class="logo">
|
||||
<h2>Reddit Video Bot</h2>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/settings" class="active">Settings</a></li>
|
||||
<li><a href="/backgrounds">Backgrounds</a></li>
|
||||
<li><a href="/videos">Videos</a></li>
|
||||
<li><a href="/progress">Progress</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<header>
|
||||
<h1>Settings</h1>
|
||||
<button id="btn-save" class="btn btn-primary">Save All Settings</button>
|
||||
</header>
|
||||
|
||||
<form id="settings-form">
|
||||
<!-- Qwen TTS Settings -->
|
||||
<div class="card">
|
||||
<h3>Qwen TTS Settings</h3>
|
||||
<p class="card-description">Configure your Qwen TTS server connection for text-to-speech generation.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qwen_api_url">API URL</label>
|
||||
<input type="url" id="qwen_api_url" name="qwen_api_url" placeholder="http://localhost:8080">
|
||||
<small>The base URL of your Qwen TTS server</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="client_secret" class="col-4">Client Secret</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-key-fill"></i>
|
||||
</div>
|
||||
<input name="client_secret" value="{{ data.client_secret }}"
|
||||
placeholder="Your Reddit app's client secret" type="text" class="form-control"
|
||||
data-toggle="tooltip" data-original-title='"Secret" on www.reddit.com/prefs/apps'>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="qwen_email">Email</label>
|
||||
<input type="email" id="qwen_email" name="qwen_email" placeholder="your@email.com">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="username" class="col-4">Reddit Username</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-person-fill"></i>
|
||||
</div>
|
||||
<input name="username" value="{{ data.username }}" placeholder="Your Reddit account's username"
|
||||
type="text" class="form-control">
|
||||
<div class="form-group">
|
||||
<label for="qwen_password">Password</label>
|
||||
<input type="password" id="qwen_password" name="qwen_password" placeholder="Enter password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="password" class="col-4">Reddit Password</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-lock-fill"></i>
|
||||
</div>
|
||||
<input name="password" value="{{ data.password }}" placeholder="Your Reddit account's password"
|
||||
type="password" class="form-control">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="qwen_speaker">Speaker Voice</label>
|
||||
<select id="qwen_speaker" name="qwen_speaker">
|
||||
<option value="Chelsie">Chelsie</option>
|
||||
<option value="Ethan">Ethan</option>
|
||||
<option value="Vivian" selected>Vivian</option>
|
||||
<option value="Asher">Asher</option>
|
||||
<option value="Aria">Aria</option>
|
||||
<option value="Oliver">Oliver</option>
|
||||
<option value="Emma">Emma</option>
|
||||
<option value="Noah">Noah</option>
|
||||
<option value="Sophia">Sophia</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="col-4">Do you have Reddit 2FA enabled?</label>
|
||||
<div class="col-8">
|
||||
<div class="form-check form-switch">
|
||||
<input name="2fa" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip"
|
||||
data-original-title='Check it if you have enabled 2FA on your Reddit account'>
|
||||
<div class="form-group">
|
||||
<label for="qwen_language">Language</label>
|
||||
<select id="qwen_language" name="qwen_language">
|
||||
<option value="English" selected>English</option>
|
||||
<option value="Chinese">Chinese</option>
|
||||
<option value="Spanish">Spanish</option>
|
||||
<option value="French">French</option>
|
||||
<option value="German">German</option>
|
||||
<option value="Japanese">Japanese</option>
|
||||
<option value="Korean">Korean</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="form-text text-muted"><a
|
||||
href="https://reddit-video-maker-bot.netlify.app/docs/configuring#setting-up-the-api"
|
||||
target="_blank">Need help? Click here to open step-by-step guide.</a></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reddit Thread -->
|
||||
<p class="h4">Reddit Thread</p>
|
||||
<div class="row mb-2">
|
||||
<label class="col-4">Random Thread</label>
|
||||
<div class="col-8">
|
||||
<div class="form-check form-switch">
|
||||
<input name="random" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip"
|
||||
data-original-title='If disabled, it will ask you for a thread link, instead of picking random one'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="subreddit" class="col-4">Subreddit</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-reddit"></i>
|
||||
</div>
|
||||
<input value="{{ data.subreddit }}" name="subreddit" type="text" class="form-control"
|
||||
placeholder="Subreddit to pull posts from (e.g. AskReddit)" data-toggle="tooltip"
|
||||
data-original-title='You can have multiple subreddits,
|
||||
add "+" between them (e.g. AskReddit+Redditdev)'>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="qwen_instruct">Voice Style Instructions</label>
|
||||
<input type="text" id="qwen_instruct" name="qwen_instruct" placeholder="Warm, friendly, conversational.">
|
||||
<small>Describe the speaking style you want</small>
|
||||
</div>
|
||||
|
||||
<button type="button" id="btn-test-tts" class="btn btn-secondary">Test TTS Connection</button>
|
||||
<span id="tts-status" class="status-text"></span>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="post_id" class="col-4">Post ID</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-file-text"></i>
|
||||
</div>
|
||||
<input value="{{ data.post_id }}" name="post_id" type="text" class="form-control"
|
||||
placeholder="Used if you want to use a specific post (e.g. urdtfx)">
|
||||
|
||||
<!-- Reddit Settings -->
|
||||
<div class="card">
|
||||
<h3>Reddit Settings</h3>
|
||||
<p class="card-description">Configure which subreddits to scrape for content. No API keys required!</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subreddit">Subreddit</label>
|
||||
<div class="input-with-prefix">
|
||||
<span class="prefix">r/</span>
|
||||
<input type="text" id="subreddit" name="subreddit" placeholder="AskReddit">
|
||||
</div>
|
||||
<small>Enter subreddit name without r/ prefix. Use + for multiple (e.g., AskReddit+nosleep)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="max_comment_length" class="col-4">Max Comment Length</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<input name="max_comment_length" type="range" class="form-range" min="10" max="10000" step="1"
|
||||
value="{{ data.max_comment_length }}" data-toggle="tooltip"
|
||||
data-original-title="{{ data.max_comment_length }}">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="min_comments">Minimum Comments</label>
|
||||
<input type="number" id="min_comments" name="min_comments" value="20" min="1">
|
||||
</div>
|
||||
<span class="form-text text-muted">Max number of characters a comment can have.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="post_lang" class="col-4">Post Language</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-translate"></i>
|
||||
</div>
|
||||
<input value="{{ data.post_lang }}" name="post_lang" type="text" class="form-control"
|
||||
placeholder="The language you would like to translate to">
|
||||
<div class="form-group">
|
||||
<label for="max_comment_length">Max Comment Length</label>
|
||||
<input type="number" id="max_comment_length" name="max_comment_length" value="500" min="50">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="min_comments" class="col-4">Minimum Comments</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<input name="min_comments" type="range" class="form-range" min="15" max="1000" step="1"
|
||||
value="{{ data.min_comments }}" data-toggle="tooltip"
|
||||
data-original-title="{{ data.min_comments }}">
|
||||
</div>
|
||||
<span class="form-text text-muted">The minimum number of comments a post should have to be
|
||||
included.</span>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="post_id">Specific Post ID (Optional)</label>
|
||||
<input type="text" id="post_id" name="post_id" placeholder="Leave empty for random">
|
||||
<small>Enter a specific Reddit post ID, or leave empty to fetch random posts</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Settings -->
|
||||
<p class="h4">General Settings</p>
|
||||
<div class="row mb-2">
|
||||
<label class="col-4">Allow NSFW</label>
|
||||
<div class="col-8">
|
||||
<div class="form-check form-switch">
|
||||
<input name="allow_nsfw" class="form-check-input" type="checkbox" value="True"
|
||||
data-toggle="tooltip" data-original-title='If checked NSFW posts will be allowed'>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="random" name="random" checked>
|
||||
Pick random posts from subreddit
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="theme" class="col-4">Reddit Theme</label>
|
||||
<div class="col-8">
|
||||
<select name="theme" class="form-select" data-toggle="tooltip"
|
||||
data-original-title='Sets the theme of Reddit screenshots'>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="allow_nsfw" name="allow_nsfw">
|
||||
Allow NSFW content
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="times_to_run" class="col-4">Times To Run</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<input name="times_to_run" type="range" class="form-range" min="1" max="1000" step="1"
|
||||
value="{{ data.times_to_run }}" data-toggle="tooltip"
|
||||
data-original-title="{{ data.times_to_run }}">
|
||||
|
||||
<!-- Video Settings -->
|
||||
<div class="card">
|
||||
<h3>Video Settings</h3>
|
||||
<p class="card-description">Configure video output and visual settings.</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="resolution_w">Width</label>
|
||||
<input type="number" id="resolution_w" name="resolution_w" value="1080">
|
||||
</div>
|
||||
<span class="form-text text-muted">Used if you want to create multiple videos.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="opacity" class="col-4">Opacity Of Comments</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<input name="opacity" type="range" class="form-range" min="0" max="1" step="0.05"
|
||||
value="{{ data.opacity }}" data-toggle="tooltip" data-original-title="{{ data.opacity }}">
|
||||
<div class="form-group">
|
||||
<label for="resolution_h">Height</label>
|
||||
<input type="number" id="resolution_h" name="resolution_h" value="1920">
|
||||
</div>
|
||||
<span class="form-text text-muted">Sets the opacity of the comments when overlayed over the
|
||||
background.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="transition" class="col-4">Transition</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<input name="transition" type="range" class="form-range" min="0" max="2" step="0.05"
|
||||
value="{{ data.transition }}" data-toggle="tooltip"
|
||||
data-original-title="{{ data.transition }}">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="theme">Screenshot Theme</label>
|
||||
<select id="theme" name="theme">
|
||||
<option value="dark" selected>Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="transparent">Transparent</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="form-text text-muted">Sets the transition time (in seconds) between the
|
||||
comments. Set to 0 if you want to disable it.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="background_choice" class="col-4">Background Choice</label>
|
||||
<div class="col-8">
|
||||
<select name="background_choice" class="form-select" data-toggle="tooltip"
|
||||
data-original-title='Sets the background of the video'>
|
||||
<option value=" ">Random Video</option>
|
||||
{% for background in checks["background_video"]["options"][1:] %}
|
||||
<option value="{{background}}">{{background}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="form-text text-muted"><a href="/backgrounds" target="_blank">See all available
|
||||
backgrounds</a></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="background_thumbnail" class="col-4">Background Thumbnail</label>
|
||||
<div class="col-8">
|
||||
<div class="form-check form-switch">
|
||||
<input name="background_thumbnail" class="form-check-input" type="checkbox" value="True"
|
||||
data-toggle="tooltip"
|
||||
data-original-title='If checked a thumbnail will be added to the video (put a thumbnail.png file in the assets/backgrounds directory for it to be used.)'>
|
||||
<div class="form-group">
|
||||
<label for="opacity">Opacity</label>
|
||||
<input type="number" id="opacity" name="opacity" value="0.9" min="0" max="1" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="background_thumbnail_font_family" class="col-4">Background Thumbnail Font Family (.ttf)</label>
|
||||
<div class="col-8">
|
||||
<input name="background_thumbnail_font_family" type="text" class="form-control"
|
||||
placeholder="arial" value="{{ data.background_thumbnail_font_family }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="background_thumbnail_font_size" class="col-4">Background Thumbnail Font Size (px)</label>
|
||||
<div class="col-8">
|
||||
<input name="background_thumbnail_font_size" type="number" class="form-control"
|
||||
placeholder="96" value="{{ data.background_thumbnail_font_size }}">
|
||||
</div>
|
||||
</div>
|
||||
<!-- need to create a color picker -->
|
||||
<div class="row mb-2">
|
||||
<label for="background_thumbnail_font_color" class="col-4">Background Thumbnail Font Color (rgb)</label>
|
||||
<div class="col-8">
|
||||
<input name="background_thumbnail_font_color" type="text" class="form-control"
|
||||
placeholder="255,255,255" value="{{ data.background_thumbnail_font_color }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS Settings -->
|
||||
<p class="h4">TTS Settings</p>
|
||||
<div class="row mb-2">
|
||||
<label for="voice_choice" class="col-4">TTS Voice Choice</label>
|
||||
<div class="col-8">
|
||||
<select name="voice_choice" class="form-select" data-toggle="tooltip"
|
||||
data-original-title='The voice platform used for TTS generation'>
|
||||
<option value="streamlabspolly">Streamlabspolly</option>
|
||||
<option value="tiktok">TikTok</option>
|
||||
<option value="googletranslate">Google Translate</option>
|
||||
<option value="awspolly">AWS Polly</option>
|
||||
<option value="pyttsx">Python TTS (pyttsx)</option>
|
||||
</select>
|
||||
<div class="form-group">
|
||||
<label for="times_to_run">Videos per Run</label>
|
||||
<input type="number" id="times_to_run" name="times_to_run" value="1" min="1" max="10">
|
||||
<small>How many videos to generate when you click "Generate"</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="aws_polly_voice" class="col-4">AWS Polly Voice</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group voices">
|
||||
<select name="aws_polly_voice" class="form-select" data-toggle="tooltip"
|
||||
data-original-title='The voice used for AWS Polly'>
|
||||
<option value="Brian">Brian</option>
|
||||
<option value="Emma">Emma</option>
|
||||
<option value="Russell">Russell</option>
|
||||
<option value="Joey">Joey</option>
|
||||
<option value="Matthew">Matthew</option>
|
||||
<option value="Joanna">Joanna</option>
|
||||
<option value="Kimberly">Kimberly</option>
|
||||
<option value="Amy">Amy</option>
|
||||
<option value="Geraint">Geraint</option>
|
||||
<option value="Nicole">Nicole</option>
|
||||
<option value="Justin">Justin</option>
|
||||
<option value="Ivy">Ivy</option>
|
||||
<option value="Kendra">Kendra</option>
|
||||
<option value="Salli">Salli</option>
|
||||
<option value="Raveena">Raveena</option>
|
||||
</select>
|
||||
|
||||
<button type="button" class="btn btn-primary"><i id="awspolly_icon"
|
||||
class="bi-volume-up-fill"></i></button>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="storymode" name="storymode">
|
||||
Story Mode (for narrative subreddits like r/nosleep)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="streamlabs_polly_voice" class="col-4">Streamlabs Polly Voice</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group voices">
|
||||
<select id="streamlabs_polly_voice" name="streamlabs_polly_voice" class="form-select"
|
||||
data-toggle="tooltip" data-original-title='The voice used for Streamlabs Polly'>
|
||||
<option value="Brian">Brian</option>
|
||||
<option value="Emma">Emma</option>
|
||||
<option value="Russell">Russell</option>
|
||||
<option value="Joey">Joey</option>
|
||||
<option value="Matthew">Matthew</option>
|
||||
<option value="Joanna">Joanna</option>
|
||||
<option value="Kimberly">Kimberly</option>
|
||||
<option value="Amy">Amy</option>
|
||||
<option value="Geraint">Geraint</option>
|
||||
<option value="Nicole">Nicole</option>
|
||||
<option value="Justin">Justin</option>
|
||||
<option value="Ivy">Ivy</option>
|
||||
<option value="Kendra">Kendra</option>
|
||||
<option value="Salli">Salli</option>
|
||||
<option value="Raveena">Raveena</option>
|
||||
</select>
|
||||
|
||||
<button type="button" class="btn btn-primary"><i id="streamlabs_icon"
|
||||
class="bi bi-volume-up-fill"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="tiktok_voice" class="col-4">TikTok Voice</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group voices">
|
||||
<select name="tiktok_voice" class="form-select" data-toggle="tooltip"
|
||||
data-original-title='The voice used for TikTok TTS'>
|
||||
<option disabled value="">-----Disney Voices-----</option>
|
||||
<option value="en_us_ghostface">Ghost Face</option>
|
||||
<option value="en_us_chewbacca">Chewbacca</option>
|
||||
<option value="en_us_c3po">C3PO</option>
|
||||
<option value="en_us_stitch">Stitch</option>
|
||||
<option value="en_us_stormtrooper">Stormtrooper</option>
|
||||
<option value="en_us_rocket">Rocket</option>
|
||||
<option disabled value="">-----English Voices-----</option>
|
||||
<option value="en_au_001">English AU - Female</option>
|
||||
<option value="en_au_002">English AU - Male</option>
|
||||
<option value="en_uk_001">English UK - Male 1</option>
|
||||
<option value="en_uk_003">English UK - Male 2</option>
|
||||
<option value="en_us_001">English US - Female (Int. 1)</option>
|
||||
<option value="en_us_002">English US - Female (Int. 2)</option>
|
||||
<option value="en_us_006">English US - Male 1</option>
|
||||
<option value="en_us_007">English US - Male 2</option>
|
||||
<option value="en_us_009">English US - Male 3</option>
|
||||
<option value="en_us_010">English US - Male 4</option>
|
||||
<option disabled value="">-----European Voices-----</option>
|
||||
<option value="fr_001">French - Male 1</option>
|
||||
<option value="fr_002">French - Male 2</option>
|
||||
<option value="de_001">German - Female</option>
|
||||
<option value="de_002">German - Male</option>
|
||||
<option value="es_002">Spanish - Male</option>
|
||||
<option disabled value="">-----American Voices-----</option>
|
||||
<option value="es_mx_002">Spanish MX - Male</option>
|
||||
<option value="br_001">Portuguese BR - Female 1</option>
|
||||
<option value="br_003">Portuguese BR - Female 2</option>
|
||||
<option value="br_004">Portuguese BR - Female 3</option>
|
||||
<option value="br_005">Portuguese BR - Male</option>
|
||||
<option disabled value="">-----Asian Voices-----</option>
|
||||
<option value="id_001">Indonesian - Female</option>
|
||||
<option value="jp_001">Japanese - Female 1</option>
|
||||
<option value="jp_003">Japanese - Female 2</option>
|
||||
<option value="jp_005">Japanese - Female 3</option>
|
||||
<option value="jp_006">Japanese - Male</option>
|
||||
<option value="kr_002">Korean - Male 1</option>
|
||||
<option value="kr_003">Korean - Female</option>
|
||||
<option value="kr_004">Korean - Male 2</option>
|
||||
<!-- Background Settings -->
|
||||
<div class="card">
|
||||
<h3>Background Settings</h3>
|
||||
<p class="card-description">Select background video and audio. Upload custom backgrounds in the Backgrounds page.</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="background_video">Background Video</label>
|
||||
<select id="background_video" name="background_video">
|
||||
<option value="minecraft">Minecraft Parkour</option>
|
||||
<option value="gta">GTA V</option>
|
||||
<option value="rocket-league">Rocket League</option>
|
||||
<option value="subway-surfers">Subway Surfers</option>
|
||||
</select>
|
||||
|
||||
<button type="button" class="btn btn-primary"><i id="tiktok_icon"
|
||||
class="bi-volume-up-fill"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="tiktok_sessionid" class="col-4">TikTok SessionId</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-mic-fill"></i>
|
||||
</div>
|
||||
<input value="{{ data.tiktok_sessionid }}" name="tiktok_sessionid" type="text" class="form-control"
|
||||
data-toggle="tooltip"
|
||||
data-original-title="TikTok sessionid needed for the TTS API request. Check documentation if you don't know how to obtain it.">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="python_voice" class="col-4">Python Voice</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-mic-fill"></i>
|
||||
</div>
|
||||
<input value="{{ data.python_voice }}" name="python_voice" type="text" class="form-control"
|
||||
data-toggle="tooltip"
|
||||
data-original-title='The index of the system TTS voices (can be downloaded externally, run ptt.py to find value, start from zero)'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="py_voice_num" class="col-4">Py Voice Number</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="bi bi-headset"></i>
|
||||
</div>
|
||||
<input value="{{ data.py_voice_num }}" name="py_voice_num" type="text" class="form-control"
|
||||
data-toggle="tooltip"
|
||||
data-original-title='The number of system voices (2 are pre-installed in Windows)'>
|
||||
<div class="form-group">
|
||||
<label for="background_audio">Background Audio</label>
|
||||
<select id="background_audio" name="background_audio">
|
||||
<option value="lofi">Lo-Fi Beats</option>
|
||||
<option value="lofi-2">Lo-Fi Beats 2</option>
|
||||
<option value="chill-summer">Chill Summer</option>
|
||||
<option value="none">No Background Audio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label for="silence_duration" class="col-4">Silence Duration</label>
|
||||
<div class="col-8">
|
||||
<div class="input-group">
|
||||
<input name="silence_duration" type="range" class="form-range" min="0" max="5" step="0.05"
|
||||
value="{{ data.silence_duration }}" data-toggle="tooltip"
|
||||
data-original-title="{{ data.silence_duration }}">
|
||||
</div>
|
||||
<span class="form-text text-muted">Time in seconds between TTS comments.</span>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="background_audio_volume">Background Audio Volume</label>
|
||||
<input type="range" id="background_audio_volume" name="background_audio_volume" min="0" max="1" step="0.05" value="0.15">
|
||||
<span id="volume-display">15%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col text-center">
|
||||
<br>
|
||||
<button id="defaultSettingsBtn" type="button" class="btn btn-secondary">Default
|
||||
Settings</button>
|
||||
<button id="submitButton" type="submit" class="btn btn-success">Save
|
||||
Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<audio src=""></audio>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Test voices buttons
|
||||
var playing = false;
|
||||
|
||||
$(".voices button").click(function () {
|
||||
var icon = $(this).find("i");
|
||||
var audio = $("audio");
|
||||
|
||||
if (playing) {
|
||||
playing.toggleClass("bi-volume-up-fill bi-stop-fill");
|
||||
|
||||
// Clicked the same button - stop audio
|
||||
if (playing.prop("id") == icon.prop("id")) {
|
||||
audio[0].pause();
|
||||
playing = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
icon.toggleClass("bi-volume-up-fill bi-stop-fill");
|
||||
let path = "voices/" + $(this).closest(".voices").find("select").prop("value").toLowerCase() + ".mp3";
|
||||
|
||||
audio.prop("src", path);
|
||||
audio[0].play();
|
||||
playing = icon;
|
||||
|
||||
audio[0].onended = function () {
|
||||
icon.toggleClass("bi-volume-up-fill bi-stop-fill");
|
||||
playing = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for DOM to load
|
||||
$(document).ready(function () {
|
||||
// Add tooltips
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
$('[data-toggle="tooltip"]').on('click', function () {
|
||||
$(this).tooltip('hide');
|
||||
});
|
||||
|
||||
// Update slider tooltip
|
||||
$(".form-range").on("input", function () {
|
||||
$(this).attr("value", $(this).val());
|
||||
$(this).attr("data-original-title", $(this).val());
|
||||
$(this).tooltip("show");
|
||||
});
|
||||
|
||||
// Get current config
|
||||
var data = JSON.parse('{{data | tojson}}');
|
||||
|
||||
// Set current checkboxes
|
||||
$('.form-check-input').each(function () {
|
||||
$(this).prop("checked", data[$(this).prop("name")]);
|
||||
});
|
||||
|
||||
// Set current select options
|
||||
$('.form-select').each(function () {
|
||||
$(this).prop("value", data[$(this).prop("name")]);
|
||||
});
|
||||
|
||||
// Submit "False" when checkbox isn't ticked
|
||||
$('#settingsForm').submit(function () {
|
||||
$('.form-check-input').each(function () {
|
||||
if (!($(this).is(':checked'))) {
|
||||
$(this).prop("value", "False");
|
||||
$(this).prop("checked", true);
|
||||
}
|
||||
</main>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSettings();
|
||||
loadCustomBackgrounds();
|
||||
|
||||
document.getElementById('btn-save').addEventListener('click', saveSettings);
|
||||
document.getElementById('btn-test-tts').addEventListener('click', testTTSConnection);
|
||||
|
||||
// Volume slider display
|
||||
const volumeSlider = document.getElementById('background_audio_volume');
|
||||
const volumeDisplay = document.getElementById('volume-display');
|
||||
volumeSlider.addEventListener('input', () => {
|
||||
volumeDisplay.textContent = Math.round(volumeSlider.value * 100) + '%';
|
||||
});
|
||||
});
|
||||
|
||||
function loadSettings() {
|
||||
fetch('/api/config')
|
||||
.then(r => r.json())
|
||||
.then(config => {
|
||||
if (!config || !config.settings) return;
|
||||
|
||||
// TTS settings
|
||||
const tts = config.settings.tts || {};
|
||||
setFieldValue('qwen_api_url', tts.qwen_api_url);
|
||||
setFieldValue('qwen_email', tts.qwen_email);
|
||||
setFieldValue('qwen_speaker', tts.qwen_speaker);
|
||||
setFieldValue('qwen_language', tts.qwen_language);
|
||||
setFieldValue('qwen_instruct', tts.qwen_instruct);
|
||||
|
||||
// Reddit settings
|
||||
const thread = config.reddit?.thread || {};
|
||||
setFieldValue('subreddit', thread.subreddit);
|
||||
setFieldValue('min_comments', thread.min_comments);
|
||||
setFieldValue('max_comment_length', thread.max_comment_length);
|
||||
setFieldValue('post_id', thread.post_id);
|
||||
setCheckbox('random', thread.random);
|
||||
setCheckbox('allow_nsfw', config.settings.allow_nsfw);
|
||||
|
||||
// Video settings
|
||||
setFieldValue('resolution_w', config.settings.resolution_w);
|
||||
setFieldValue('resolution_h', config.settings.resolution_h);
|
||||
setFieldValue('theme', config.settings.theme);
|
||||
setFieldValue('opacity', config.settings.opacity);
|
||||
setFieldValue('times_to_run', config.settings.times_to_run);
|
||||
setCheckbox('storymode', config.settings.storymode);
|
||||
|
||||
// Background settings
|
||||
const bg = config.settings.background || {};
|
||||
setFieldValue('background_video', bg.background_video);
|
||||
setFieldValue('background_audio', bg.background_audio);
|
||||
setFieldValue('background_audio_volume', bg.background_audio_volume);
|
||||
|
||||
// Update volume display
|
||||
const vol = bg.background_audio_volume || 0.15;
|
||||
document.getElementById('volume-display').textContent = Math.round(vol * 100) + '%';
|
||||
})
|
||||
.catch(err => console.error('Error loading settings:', err));
|
||||
}
|
||||
|
||||
// Get validation values
|
||||
let validateChecks = JSON.parse('{{checks | tojson}}');
|
||||
|
||||
// Set default values
|
||||
$("#defaultSettingsBtn").click(function (event) {
|
||||
$("#settingsForm input, #settingsForm select").each(function () {
|
||||
let check = validateChecks[$(this).prop("name")];
|
||||
|
||||
if (check["default"]) {
|
||||
$(this).prop("value", check["default"]);
|
||||
|
||||
// Update tooltip value for input[type="range"]
|
||||
if ($(this).prop("type") == "range") {
|
||||
$(this).attr("data-original-title", check["default"]);
|
||||
function loadCustomBackgrounds() {
|
||||
fetch('/api/backgrounds/video')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const videoSelect = document.getElementById('background_video');
|
||||
|
||||
// Add custom videos
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
const customGroup = document.createElement('optgroup');
|
||||
customGroup.label = 'Custom Videos';
|
||||
data.videos.forEach(v => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'custom:' + v.filename;
|
||||
opt.textContent = v.name;
|
||||
customGroup.appendChild(opt);
|
||||
});
|
||||
videoSelect.appendChild(customGroup);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Validate form
|
||||
$('#settingsForm').submit(function (event) {
|
||||
$("#settingsForm input, #settingsForm select").each(function () {
|
||||
if (!(validate($(this)))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
$("html, body").animate({
|
||||
scrollTop: $(this).offset().top
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
$("#settingsForm input").on("keyup", function () {
|
||||
validate($(this));
|
||||
});
|
||||
|
||||
$("#settingsForm select").on("change", function () {
|
||||
validate($(this));
|
||||
});
|
||||
|
||||
function validate(object) {
|
||||
let bool = check(object.prop("name"), object.prop("value"));
|
||||
})
|
||||
.catch(err => console.error('Error loading backgrounds:', err));
|
||||
}
|
||||
|
||||
// Change class
|
||||
if (bool) {
|
||||
object.removeClass("is-invalid");
|
||||
object.addClass("is-valid");
|
||||
}
|
||||
else {
|
||||
object.removeClass("is-valid");
|
||||
object.addClass("is-invalid");
|
||||
function setFieldValue(id, value) {
|
||||
const field = document.getElementById(id);
|
||||
if (field && value !== undefined && value !== null) {
|
||||
field.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
return bool;
|
||||
|
||||
// Check values (return true/false)
|
||||
function check(name, value) {
|
||||
let check = validateChecks[name];
|
||||
|
||||
// If value is empty - check if it's optional
|
||||
if (value.length == 0) {
|
||||
if (check["optional"] == false) {
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
object.prop("value", check["default"]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if value is too short
|
||||
if (check["nmin"]) {
|
||||
if (check["type"] == "int" || check["type"] == "float") {
|
||||
if (value < check["nmin"]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (value.length < check["nmin"]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Check if value is too long
|
||||
if (check["nmax"]) {
|
||||
if (check["type"] == "int" || check["type"] == "float") {
|
||||
if (value > check["nmax"]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (value.length > check["nmax"]) {
|
||||
return false;
|
||||
}
|
||||
function setCheckbox(id, value) {
|
||||
const field = document.getElementById(id);
|
||||
if (field) {
|
||||
field.checked = !!value;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
function saveSettings() {
|
||||
const form = document.getElementById('settings-form');
|
||||
const config = {
|
||||
subreddit: form.subreddit.value,
|
||||
post_id: form.post_id.value,
|
||||
min_comments: parseInt(form.min_comments.value),
|
||||
max_comment_length: parseInt(form.max_comment_length.value),
|
||||
random: form.random.checked,
|
||||
allow_nsfw: form.allow_nsfw.checked,
|
||||
theme: form.theme.value,
|
||||
opacity: parseFloat(form.opacity.value),
|
||||
times_to_run: parseInt(form.times_to_run.value),
|
||||
storymode: form.storymode.checked,
|
||||
resolution_w: parseInt(form.resolution_w.value),
|
||||
resolution_h: parseInt(form.resolution_h.value),
|
||||
voice_choice: 'qwentts',
|
||||
qwen_api_url: form.qwen_api_url.value,
|
||||
qwen_email: form.qwen_email.value,
|
||||
qwen_password: form.qwen_password.value,
|
||||
qwen_speaker: form.qwen_speaker.value,
|
||||
qwen_language: form.qwen_language.value,
|
||||
qwen_instruct: form.qwen_instruct.value,
|
||||
background_video: form.background_video.value,
|
||||
background_audio: form.background_audio.value,
|
||||
background_audio_volume: parseFloat(form.background_audio_volume.value)
|
||||
};
|
||||
|
||||
fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showNotification('Settings saved successfully!', 'success');
|
||||
} else {
|
||||
showNotification('Failed to save: ' + data.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => showNotification('Error saving settings', 'error'));
|
||||
}
|
||||
|
||||
// Check if value matches regex
|
||||
if (check["regex"]) {
|
||||
let regex = new RegExp(check["regex"]);
|
||||
if (!(regex.test(value))) {
|
||||
return false;
|
||||
}
|
||||
function testTTSConnection() {
|
||||
const statusEl = document.getElementById('tts-status');
|
||||
statusEl.textContent = 'Testing...';
|
||||
statusEl.className = 'status-text';
|
||||
|
||||
const config = {
|
||||
qwen_api_url: document.getElementById('qwen_api_url').value,
|
||||
qwen_email: document.getElementById('qwen_email').value,
|
||||
qwen_password: document.getElementById('qwen_password').value
|
||||
};
|
||||
|
||||
fetch('/api/tts/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
statusEl.textContent = 'Connection successful!';
|
||||
statusEl.className = 'status-text success';
|
||||
} else {
|
||||
statusEl.textContent = 'Failed: ' + data.message;
|
||||
statusEl.className = 'status-text error';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.textContent = 'Connection error';
|
||||
statusEl.className = 'status-text error';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -0,0 +1,774 @@
|
||||
/* Reddit Video Maker Bot - Main Application Styles */
|
||||
|
||||
:root {
|
||||
--bg-primary: #0f0f0f;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #252525;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #666666;
|
||||
--accent-primary: #ff4500;
|
||||
--accent-secondary: #ff6b35;
|
||||
--success: #4caf50;
|
||||
--warning: #ff9800;
|
||||
--error: #f44336;
|
||||
--info: #2196f3;
|
||||
--border-color: #333333;
|
||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--sidebar-width: 240px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar .logo {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sidebar .logo h2 {
|
||||
font-size: 1.2rem;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-links li a {
|
||||
display: block;
|
||||
padding: 12px 24px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-links li a:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-links li a.active {
|
||||
background: rgba(255, 69, 0, 0.1);
|
||||
color: var(--accent-primary);
|
||||
border-left-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 30px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.content header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.content header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--card-shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* Dashboard Grid */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.card-wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 14px 28px;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-group input[type="range"] {
|
||||
padding: 0;
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.input-with-prefix {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.input-with-prefix .prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-right: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-with-prefix input {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Status Indicator */
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.status-indicator .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-indicator.running .dot {
|
||||
background: var(--info);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Config Summary */
|
||||
.config-summary .config-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.config-summary .config-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-summary .warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.config-summary a {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.progress-info {
|
||||
padding: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.progress-subreddit {
|
||||
color: var(--accent-primary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
height: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-steps .step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.progress-steps .step.completed {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.progress-steps .step.in_progress {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.progress-steps .step.failed {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-progress {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-progress .hint {
|
||||
margin-top: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Video Items */
|
||||
.video-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.video-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.video-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-subreddit {
|
||||
color: var(--accent-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Videos Grid */
|
||||
.videos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.video-card .video-preview {
|
||||
margin: -24px -24px 16px -24px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-card .video-preview video {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-card .video-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.video-card .video-name {
|
||||
font-size: 1rem;
|
||||
margin-top: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.video-card .video-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.video-card .video-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Media Grid */
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.media-preview video {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-preview audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.media-size {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Upload Progress */
|
||||
.upload-progress {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px 24px;
|
||||
box-shadow: var(--card-shadow);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.upload-progress.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-progress-bar {
|
||||
width: 200px;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-primary);
|
||||
animation: indeterminate 1.5s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(200%); }
|
||||
}
|
||||
|
||||
/* Status Text */
|
||||
.status-text {
|
||||
margin-left: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-text.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-text.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Filter Select */
|
||||
.filter-select {
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
.notification {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.notification.info {
|
||||
background: var(--info);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar .logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.nav-links li a {
|
||||
padding: 12px 16px;
|
||||
white-space: nowrap;
|
||||
border-left: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-links li a.active {
|
||||
border-left-color: transparent;
|
||||
border-bottom-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-left: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.videos-grid,
|
||||
.media-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Reddit Video Maker Bot - Shared JavaScript Utilities
|
||||
*/
|
||||
|
||||
// Show notification toast
|
||||
function showNotification(message, type = 'info') {
|
||||
const notif = document.createElement('div');
|
||||
notif.className = `notification ${type}`;
|
||||
notif.textContent = message;
|
||||
document.body.appendChild(notif);
|
||||
setTimeout(() => {
|
||||
notif.style.opacity = '0';
|
||||
notif.style.transform = 'translateY(20px)';
|
||||
setTimeout(() => notif.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Format duration in seconds to mm:ss
|
||||
function formatDuration(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format ISO date string to readable format
|
||||
function formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Debounce function for search inputs
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Copy text to clipboard
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showNotification('Copied to clipboard!', 'success');
|
||||
} catch (err) {
|
||||
showNotification('Failed to copy', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// API helper functions
|
||||
const api = {
|
||||
get: async (url) => {
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
},
|
||||
|
||||
post: async (url, data) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
delete: async (url) => {
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
return response.json();
|
||||
},
|
||||
|
||||
upload: async (url, file, onProgress) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress((e.loaded / e.total) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
reject(new Error(xhr.statusText));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
|
||||
xhr.open('POST', url);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// WebSocket connection manager for progress updates
|
||||
class ProgressSocket {
|
||||
constructor(namespace = '/progress') {
|
||||
this.socket = null;
|
||||
this.namespace = namespace;
|
||||
this.callbacks = [];
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (typeof io !== 'undefined') {
|
||||
this.socket = io(this.namespace);
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to progress socket');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('Disconnected from progress socket');
|
||||
});
|
||||
|
||||
this.socket.on('progress_update', (data) => {
|
||||
this.callbacks.forEach(cb => cb(data));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(callback) {
|
||||
this.callbacks.push(callback);
|
||||
}
|
||||
|
||||
requestStatus() {
|
||||
if (this.socket) {
|
||||
this.socket.emit('request_status');
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
window.showNotification = showNotification;
|
||||
window.formatBytes = formatBytes;
|
||||
window.formatDuration = formatDuration;
|
||||
window.formatDate = formatDate;
|
||||
window.debounce = debounce;
|
||||
window.escapeHtml = escapeHtml;
|
||||
window.copyToClipboard = copyToClipboard;
|
||||
window.api = api;
|
||||
window.ProgressSocket = ProgressSocket;
|
||||
@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Videos - Reddit Video Maker Bot</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
<div class="logo">
|
||||
<h2>Reddit Video Bot</h2>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><a href="/backgrounds">Backgrounds</a></li>
|
||||
<li><a href="/videos" class="active">Videos</a></li>
|
||||
<li><a href="/progress">Progress</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<header>
|
||||
<h1>Generated Videos</h1>
|
||||
<div class="header-actions">
|
||||
<select id="filter-subreddit" class="filter-select">
|
||||
<option value="">All Subreddits</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="videos-container">
|
||||
<div class="loading">Loading videos...</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script>
|
||||
let allVideos = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadVideos();
|
||||
document.getElementById('filter-subreddit').addEventListener('change', filterVideos);
|
||||
});
|
||||
|
||||
function loadVideos() {
|
||||
fetch('/api/videos')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
allVideos = data.videos || [];
|
||||
populateFilters();
|
||||
renderVideos(allVideos);
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('videos-container').innerHTML = '<div class="error">Error loading videos</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function populateFilters() {
|
||||
const subreddits = [...new Set(allVideos.map(v => v.subreddit))];
|
||||
const select = document.getElementById('filter-subreddit');
|
||||
subreddits.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s;
|
||||
opt.textContent = 'r/' + s;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function filterVideos() {
|
||||
const subreddit = document.getElementById('filter-subreddit').value;
|
||||
const filtered = subreddit
|
||||
? allVideos.filter(v => v.subreddit === subreddit)
|
||||
: allVideos;
|
||||
renderVideos(filtered);
|
||||
}
|
||||
|
||||
function renderVideos(videos) {
|
||||
const container = document.getElementById('videos-container');
|
||||
|
||||
if (videos.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state card">
|
||||
<h3>No Videos Yet</h3>
|
||||
<p>Generated videos will appear here.</p>
|
||||
<a href="/" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="videos-grid">
|
||||
${videos.map(v => `
|
||||
<div class="video-card card">
|
||||
<div class="video-preview">
|
||||
<video src="${v.path}" controls preload="metadata"></video>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<span class="video-subreddit">r/${v.subreddit}</span>
|
||||
<h4 class="video-name">${v.name}</h4>
|
||||
<div class="video-meta">
|
||||
<span class="video-size">${formatFileSize(v.size)}</span>
|
||||
<span class="video-date">${formatDate(v.created)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-actions">
|
||||
<a href="${v.path}" download class="btn btn-primary btn-small">Download</a>
|
||||
<button class="btn btn-secondary btn-small" onclick="copyLink('${v.path}')">Copy Link</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function copyLink(path) {
|
||||
const url = window.location.origin + path;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
showNotification('Link copied to clipboard!', 'success');
|
||||
}).catch(() => {
|
||||
showNotification('Failed to copy link', 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in new issue