Multi-tenant platform: per-streamer overlay/sender links + CI/CD
Some checks failed
CI/CD Pipeline / build-and-deploy (push) Failing after 2s
Some checks failed
CI/CD Pipeline / build-and-deploy (push) Failing after 2s
Streamers log in with Google on a new landing page and get a dashboard with two auto-generated, permanent links: one for their OBS/StreamLabs Browser Source, one to share with viewers. Messages are now scoped to the right streamer via Socket.IO rooms instead of broadcasting globally. Streamer records and tokens are persisted in SQLite (node:sqlite) so links survive restarts/redeploys. Also adds a Forgejo Actions pipeline mirroring t2sTelegramBot's: build, smoke-test, push to the local registry, then deploy to the prod host over SSH. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7882db1f37
commit
9c91966730
14 changed files with 444 additions and 34 deletions
115
public/dashboard.html
Normal file
115
public/dashboard.html
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Ваши ссылки — Send2Streamer</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 640px;
|
||||
margin: 60px auto;
|
||||
padding: 0 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1 { font-size: 28px; }
|
||||
.card {
|
||||
background: #f6f6f6;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.card p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.url-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.url-row input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
.url-row button {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#logout {
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Ваши ссылки</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Ссылка для OBS / StreamLabs</h2>
|
||||
<p>Вставьте в свойства Browser Source (URL).</p>
|
||||
<div class="url-row">
|
||||
<input id="overlay-url" readonly />
|
||||
<button data-copy="overlay-url">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Ссылка для зрителей</h2>
|
||||
<p>Опубликуйте её — зрители авторизуются через Google и смогут слать вам сообщения на стрим.</p>
|
||||
<div class="url-row">
|
||||
<input id="sender-url" readonly />
|
||||
<button data-copy="sender-url">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="logout">выйти</p>
|
||||
|
||||
<script>
|
||||
async function init() {
|
||||
const meRes = await fetch('/me');
|
||||
const { user } = await meRes.json();
|
||||
if (!user) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/dashboard');
|
||||
if (!res.ok) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
document.getElementById('overlay-url').value = data.overlayUrl;
|
||||
document.getElementById('sender-url').value = data.senderUrl;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-copy]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const input = document.getElementById(btn.dataset.copy);
|
||||
input.select();
|
||||
navigator.clipboard.writeText(input.value);
|
||||
btn.textContent = 'Скопировано';
|
||||
setTimeout(() => { btn.textContent = 'Копировать'; }, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('logout').addEventListener('click', async () => {
|
||||
await fetch('/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
100
public/index.html
Normal file
100
public/index.html
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Send2Streamer — сообщения от зрителей прямо на стрим</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 640px;
|
||||
margin: 60px auto;
|
||||
padding: 0 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.steps {
|
||||
background: #f6f6f6;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.steps li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#login-container {
|
||||
margin-top: 8px;
|
||||
}
|
||||
#status {
|
||||
margin-top: 12px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Send2Streamer</h1>
|
||||
<p class="subtitle">Зрители пишут сообщения — они всплывают у вас на стриме голосом.</p>
|
||||
|
||||
<div class="steps">
|
||||
<p><strong>Как это работает:</strong></p>
|
||||
<ol>
|
||||
<li>Вы входите через Google — сервис выдаёт вам две ссылки: для OBS/StreamLabs и для зрителей.</li>
|
||||
<li>Первую вставляете в Browser Source в OBS/StreamLabs.</li>
|
||||
<li>Вторую публикуете для зрителей (например, в описании канала или в чате).</li>
|
||||
<li>Зритель авторизуется через Google по этой ссылке и пишет вам сообщение — оно появляется на стриме с текстом и озвучкой.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p><strong>Войдите как стример, чтобы получить свои ссылки:</strong></p>
|
||||
<div id="login-container"></div>
|
||||
<div id="status"></div>
|
||||
|
||||
<script>
|
||||
async function handleCredentialResponse(response) {
|
||||
const statusEl = document.getElementById('status');
|
||||
const res = await fetch('/auth/google', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: response.credential }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = '/dashboard.html';
|
||||
} else {
|
||||
statusEl.textContent = 'Не удалось войти, попробуйте ещё раз';
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const meRes = await fetch('/me');
|
||||
const { user } = await meRes.json();
|
||||
if (user) {
|
||||
window.location.href = '/dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const configRes = await fetch('/config');
|
||||
const { googleClientId } = await configRes.json();
|
||||
|
||||
google.accounts.id.initialize({
|
||||
client_id: googleClientId,
|
||||
callback: handleCredentialResponse,
|
||||
});
|
||||
google.accounts.id.renderButton(document.getElementById('login-container'), { theme: 'outline', size: 'large' });
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
const box = document.getElementById('box');
|
||||
const fromEl = document.getElementById('from');
|
||||
const textEl = document.getElementById('text');
|
||||
const socket = io();
|
||||
|
||||
const token = location.pathname.split('/').filter(Boolean).pop();
|
||||
const socket = io({ query: { role: 'overlay', token } });
|
||||
|
||||
let hideTimer = null;
|
||||
let audioCtx = null;
|
||||
|
|
@ -70,3 +72,7 @@ socket.on('display_message', (payload) => {
|
|||
socket.on('display_audio', (payload) => {
|
||||
playSpeech(payload.audio, payload.mimeType);
|
||||
});
|
||||
|
||||
socket.on('invalid_link', () => {
|
||||
console.error('Invalid overlay link — check the URL from your dashboard.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Отправить сообщение стримеру</title>
|
||||
<title>Отправить сообщение на стрим</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<style>
|
||||
|
|
@ -12,6 +12,15 @@
|
|||
margin: 60px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
#user-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -56,6 +65,9 @@
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Сообщение на стрим</h1>
|
||||
<p class="subtitle">Войдите через Google и напишите сообщение — оно появится на стриме с озвучкой.</p>
|
||||
|
||||
<div id="login-container"></div>
|
||||
|
||||
<div id="hidden-until-login">
|
||||
|
|
@ -65,13 +77,13 @@
|
|||
<span id="logout">выйти</span>
|
||||
</div>
|
||||
|
||||
<textarea id="message" placeholder="Текст для оверлея..." maxlength="500"></textarea>
|
||||
<textarea id="message" placeholder="Текст для стрима..." maxlength="500"></textarea>
|
||||
<br />
|
||||
<button id="send">Отправить</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/sender.js"></script>
|
||||
<script src="/send.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -7,7 +7,8 @@ const messageInput = document.getElementById('message');
|
|||
const sendBtn = document.getElementById('send');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
const socket = io();
|
||||
const token = location.pathname.split('/').filter(Boolean).pop();
|
||||
const socket = io({ query: { role: 'sender', token } });
|
||||
|
||||
function showLoggedIn(user) {
|
||||
loginContainer.style.display = 'none';
|
||||
|
|
@ -62,7 +63,7 @@ async function init() {
|
|||
sendBtn.addEventListener('click', () => {
|
||||
const text = messageInput.value.trim();
|
||||
if (!text) return;
|
||||
socket.emit('send_message', text);
|
||||
socket.emit('send_message', { token, text });
|
||||
messageInput.value = '';
|
||||
statusEl.textContent = 'Отправлено';
|
||||
setTimeout(() => { statusEl.textContent = ''; }, 1500);
|
||||
Loading…
Add table
Add a link
Reference in a new issue