heystreamer/templates/layout.html
Bashy 6ad661c220 Initial commit — BashyOverlay v1
Twitch stream event platform with OBS browser source overlay.

Features:
- Twitch OAuth login (streamer + mod access)
- EventSub WebSocket for subs, gift subs, bits, channel points, follows, raids
- Chat commands via tmi.js in the overlay
- Gap audio — tone plays on new chat message after silence, volume/pitch scale with gap length
- Self-hosted media library (upload sounds and videos)
- Action configuration — map any event to sound, video, or alert
- Per-action cooldowns, test button, enable/disable toggle
- Multiple actions per event (all matching actions fire)
- Activity log dashboard with EventSub connection status
- Layout editor — iframe-based WYSIWYG with drag handles, live style preview
- Custom CSS and custom JS injection into overlay
- Custom DOM events (bashyoverlay:sub, bashyoverlay:raid, etc.) for custom JS hooks
- deploy.sh — one-shot setup and launch script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:48:35 +03:00

200 lines
8.3 KiB
HTML

{% extends "base.html" %}
{% block title %}Layout Editor — BashyOverlay{% endblock %}
{% block content %}
<hgroup>
<h1>Layout Editor</h1>
<p>Drag handles to reposition. Resize chat from the bottom-right corner. Changes to styles preview live.</p>
</hgroup>
{% if saved %}
<div class="notice-success">Styles saved. Refresh your OBS browser source to apply.</div>
{% endif %}
<div class="editor-wrap" id="editor-wrap">
<iframe id="preview-frame"
src="/overlay/preview"
scrolling="no"
style="position:absolute;top:0;left:0;width:100%;height:100%;border:none;pointer-events:none;">
</iframe>
<div class="drag-handle handle-chat"
id="handle-chat"
data-target="chat"
data-left="{{ layout.chat_left }}"
data-top="{{ layout.chat_top }}"
data-width="{{ layout.chat_width }}"
data-height="{{ layout.chat_height }}"
style="left:{{ layout.chat_left }}%;top:{{ layout.chat_top }}%;width:{{ layout.chat_width }}%;height:{{ layout.chat_height }}%">
<div class="handle-label">Chat</div>
<div class="resize-corner"></div>
</div>
<div class="drag-handle handle-alert"
id="handle-alert"
data-target="alert"
data-left="{{ layout.alert_left }}"
data-top="{{ layout.alert_top }}"
style="left:{{ layout.alert_left }}%;top:{{ layout.alert_top }}%;transform:translate(-50%,-50%)">
<div class="handle-label">Alert</div>
</div>
</div>
<div class="canvas-footer">
<small class="muted">Positions save on drag end. Refresh OBS browser source after repositioning.</small>
<button type="button" id="preview-alert-btn" class="outline small-btn">▶ Preview alert</button>
</div>
<form method="post" action="/layout" class="style-form">
<div class="props-grid">
<fieldset>
<legend><strong>Chat</strong></legend>
<div class="props-row">
<label>
Font size (px)
<input type="number" name="chat_font_size" value="{{ layout.chat_font_size }}" min="8" max="48">
</label>
<label>
BG opacity
<input type="range" name="chat_bg_opacity" value="{{ layout.chat_bg_opacity }}"
min="0" max="1" step="0.05"
oninput="this.nextElementSibling.textContent = (+this.value).toFixed(2)">
<span>{{ layout.chat_bg_opacity }}</span>
</label>
<label>
Text color
<input type="color" name="chat_text_color" value="{{ layout.chat_text_color }}">
</label>
<label>
Max messages
<input type="number" name="chat_max_messages" value="{{ layout.chat_max_messages }}" min="1" max="50">
</label>
</div>
</fieldset>
<fieldset>
<legend><strong>Alert</strong></legend>
<div class="props-row">
<label>
Font size (px)
<input type="number" name="alert_font_size" value="{{ layout.alert_font_size }}" min="8" max="72">
</label>
<label>
BG opacity
<input type="range" name="alert_bg_opacity" value="{{ layout.alert_bg_opacity }}"
min="0" max="1" step="0.05"
oninput="this.nextElementSibling.textContent = (+this.value).toFixed(2)">
<span>{{ layout.alert_bg_opacity }}</span>
</label>
<label>
Text color
<input type="color" name="alert_text_color" value="{{ layout.alert_text_color }}">
</label>
<label>
Duration (sec)
<input type="number" name="alert_duration" value="{{ layout.alert_duration }}" min="1" max="30">
</label>
</div>
</fieldset>
</div>
<button type="submit">Save styles</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/interactjs@1.10.27/dist/interact.min.js"></script>
<script>
const wrap = document.getElementById('editor-wrap');
const iframe = document.getElementById('preview-frame');
function iframeDoc() { return iframe.contentDocument || iframe.contentWindow?.document; }
function iframeVar(cssVar, value) {
const doc = iframeDoc();
if (doc) doc.documentElement.style.setProperty(cssVar, value);
}
// ── Live style preview ────────────────────────────────────────────────────
function bind(name, cssVar, transform = v => v) {
const el = document.querySelector(`[name="${name}"]`);
if (!el) return;
el.addEventListener('input', () => iframeVar(cssVar, transform(el.value)));
}
bind('chat_font_size', '--chat-font-size', v => v + 'px');
bind('chat_bg_opacity', '--chat-bg-opacity');
bind('chat_text_color', '--chat-text-color');
bind('alert_font_size', '--alert-font-size', v => v + 'px');
bind('alert_bg_opacity', '--alert-bg-opacity');
bind('alert_text_color', '--alert-text-color');
// ── Preview alert button ──────────────────────────────────────────────────
document.getElementById('preview-alert-btn').addEventListener('click', () => {
iframe.contentWindow?.postMessage({ type: 'preview-alert' }, '*');
});
// ── Drag ──────────────────────────────────────────────────────────────────
interact('.drag-handle').draggable({
listeners: {
move(event) {
const el = event.target;
const ww = wrap.offsetWidth;
const wh = wrap.offsetHeight;
let left = parseFloat(el.dataset.left || 0);
let top = parseFloat(el.dataset.top || 0);
left = Math.max(0, Math.min(95, left + event.dx / ww * 100));
top = Math.max(0, Math.min(95, top + event.dy / wh * 100));
el.style.left = left + '%';
el.style.top = top + '%';
el.dataset.left = left;
el.dataset.top = top;
const t = el.dataset.target;
iframeVar(`--${t}-left`, left + '%');
iframeVar(`--${t}-top`, top + '%');
},
end(event) {
const el = event.target;
savePos(el.dataset.target,
parseFloat(el.dataset.left),
parseFloat(el.dataset.top),
parseFloat(el.dataset.width || 0),
parseFloat(el.dataset.height || 0));
}
}
});
// ── Resize (chat only) ────────────────────────────────────────────────────
interact('#handle-chat').resizable({
edges: { right: true, bottom: true },
modifiers: [interact.modifiers.restrictSize({ min: { width: 30, height: 30 } })],
listeners: {
move(event) {
const el = event.target;
const ww = wrap.offsetWidth;
const wh = wrap.offsetHeight;
const w = Math.max(5, Math.min(100, event.rect.width / ww * 100));
const h = Math.max(5, Math.min(100, event.rect.height / wh * 100));
el.style.width = w + '%';
el.style.height = h + '%';
el.dataset.width = w;
el.dataset.height = h;
iframeVar('--chat-width', w + '%');
iframeVar('--chat-height', h + '%');
},
end(event) {
const el = event.target;
savePos('chat',
parseFloat(el.dataset.left),
parseFloat(el.dataset.top),
parseFloat(el.dataset.width),
parseFloat(el.dataset.height));
}
}
});
async function savePos(target, left, top, width, height) {
await fetch('/layout/positions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ widget: target, left, top, width, height })
});
}
</script>
{% endblock %}