Complete overhaul of Printer Bot dock

First stage of widget-dock-core
This commit is contained in:
nuttylmao
2025-09-06 01:08:14 +10:00
parent befefc0eb3
commit 7e20a30c67
21 changed files with 1740 additions and 969 deletions
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 2000 1556" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(30.772,0,0,30.772,323.017,878.74)">
<path d="M32.927,8.977L22,22L11.073,8.977C14.134,6.408 18.003,5 22,5C25.997,5 29.866,6.408 32.927,8.977ZM-5.271,-10.5L-10.497,-16.729C-1.392,-24.369 10.114,-28.557 22,-28.557C33.886,-28.557 45.392,-24.369 54.497,-16.729L49.271,-10.5C41.63,-16.912 31.974,-20.426 22,-20.426C12.026,-20.426 2.37,-16.912 -5.271,-10.5ZM4.373,0.993L0.145,-4.046C6.269,-9.184 14.007,-12 22,-12C29.993,-12 37.731,-9.184 43.855,-4.046L39.627,0.993C34.688,-3.151 28.447,-5.422 22,-5.422C15.553,-5.422 9.312,-3.151 4.373,0.993Z" style="fill:rgb(0,230,118);"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1865 1556" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(86.4291,0,0,86.4291,-901.44,-432.146)">
<path d="M32,9L32,15L30,15L30,9L32,9ZM32,17L32,19L30,19L30,17L32,17ZM28.048,9.158C26.155,8.331 24.097,7.895 22,7.895C18.449,7.895 15.011,9.146 12.291,11.429L10.43,9.211C13.672,6.491 17.768,5 22,5C24.923,5 27.782,5.712 30.339,7.048L30,7.048C28.922,7.048 28.048,7.922 28.048,9L28.048,9.158ZM28.082,15.362C26.355,13.988 24.212,13.237 22,13.237C19.705,13.237 17.483,14.045 15.724,15.521L14.219,13.727C16.399,11.897 19.154,10.895 22,10.895C24.135,10.895 26.22,11.459 28.048,12.514L28.048,15C28.048,15.124 28.06,15.245 28.082,15.362ZM25.891,18.363L22,23L18.109,18.363C19.2,17.449 20.577,16.947 22,16.947C23.423,16.947 24.8,17.449 25.891,18.363Z" style="fill:rgb(221,44,0);"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<link rel="shortcut icon" href="#" />
<link rel="stylesheet" href="../../styles/global.css" />
<link rel="stylesheet" href="./style.css" />
<script type="text/javascript" src="https://unpkg.com/@streamerbot/client/dist/streamerbot-client.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3/build/global/luxon.min.js"></script>
</head>
<body>
<!-- Header -->
<div id="header">
<img src="../../resources/logo.png" style="height: 40px;" />
<label id="title" class="title"></label>
<div id="header-end">
<button id="sb-actions-button" onclick="OpenRequiredActionsDialog()">
</button>
<button id="sb-status-button" onclick="OpenConnectDialog()">
<img id="sb-status-icon" style="height: 1em;" />
</button>
</div>
</div>
<!-- Page Contents -->
<iframe id="content">
</iframe>
<!-- Blur Layer -->
<div id="blur-layer"></div>
<!-- Streamer.bot Required Actions Dialog -->
<div id="sb-required-actions-dialog" class="dialog">
<button class="dialog-nav-button" onclick="CloseRequiredActionsDialog()">×</button>
<label id="sb-required-actions-success">✅ All actions found</label>
<label id="sb-required-actions-failure">⚠️ You are missing Streamer.bot actions</label>
<label id="sb-required-actions-failure-subtext" class="setting-description">The following actions were not found in Streamer.bot:</label>
<div id="sb-required-actions-list" class="callout">
</div>
<label class="setting-description">Please import the following </label>
<div id="sb-import-wrapper">
<label id="sb-import-code" class="callout"></label>
<button id="sb-import-copy-button" onclick="CopyImportCode()">Copy</button>
</div>
<button onclick="CheckRequiredActions()">Check Again</button>
</div>
<!-- Streamer.bot Connect Dialog -->
<div id="sb-connect-dialog" class="dialog">
<hr class="divider">
<div style="display: flex; flex-direction: row; gap: 1em">
<div class="field">
<label for="sb-address">Address *</label>
<input type="text" id="sb-address" name="sb-address" placeholder="127.0.0.1" value="127.0.0.1">
</div>
<div class="field">
<label for="sb-port">Port *</label>
<input type="text" id="sb-port" name="sb-port" placeholder="8080" value="8080">
</div>
</div>
<div class="field">
<label for="sb-password">Password</label>
<label class="setting-description" for="sb-password">(Optional)</label>
<input type="password" id="sb-password" name="sb-password">
</div>
<hr class="divider">
<label id="sb-error-label"></label>
<button id="sb-connect-button" onclick="Connect()">Connect</button>
</div>
</body>
<script src="./script.js"></script>
+271
View File
@@ -0,0 +1,271 @@
////////////////
// PARAMETERS //
////////////////
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const configJson = urlParams.get("config") || "";
///////////////////
// PAGE ELEMENTS //
///////////////////
const sbConnectDialog = document.getElementById('sb-connect-dialog');
const sbAddressInput = document.getElementById('sb-address');
const sbPortInput = document.getElementById('sb-port');
const sbPasswordInput = document.getElementById('sb-password');
const sbErrorLabel = document.getElementById('sb-error-label');
const sbRequiredActionsDialog = document.getElementById('sb-required-actions-dialog');
const sbRequiredActionsSuccessLabel = document.getElementById('sb-required-actions-success');
const sbRequiredActionsFailureLabel = document.getElementById('sb-required-actions-failure');
const sbRequiredActionsFailureSubtext = document.getElementById('sb-required-actions-failure-subtext');
const sbRequiredActionsList = document.getElementById('sb-required-actions-list');
const sbImportCodeLabel = document.getElementById('sb-import-code');
const sbImportCopyButton = document.getElementById('sb-import-copy-button');
const sbActionsButton = document.getElementById('sb-actions-button');
const sbStatusButton = document.getElementById('sb-status-button');
const sbStatusIcon = document.getElementById('sb-status-icon');
const blurLayer = document.getElementById('blur-layer');
/////////////////////////
// STREAMER.BOT CLIENT //
/////////////////////////
// Check local storage
if (localStorage.getItem('sbServerAddress') === null)
localStorage.setItem('sbServerAddress', '127.0.0.1');
if (localStorage.getItem('sbServerPort') === null)
localStorage.setItem('sbServerPort', '8080');
sbAddressInput.value = localStorage.getItem('sbServerAddress');
sbPortInput.value = localStorage.getItem('sbServerPort');
sbPasswordInput.value = localStorage.getItem('sbServerPassword');
const sbServerAddress = sbAddressInput.value;
const sbServerPort = sbPortInput.value;
const sbServerPassword = sbPasswordInput.value;
window.sbClient = new StreamerbotClient({
host: sbServerAddress,
port: sbServerPort,
password: sbServerPassword,
immediate: true,
onConnect: (data) => {
console.log(`Streamer.bot successfully connected to ${sbServerAddress}:${sbServerPort}`)
console.debug(data);
SetConnectionState(true);
},
onDisconnect: () => {
console.error(`Streamer.bot disconnected from ${sbServerAddress}:${sbServerPort}`)
SetConnectionState(false);
},
onError: (err) => {
console.error(`Streamer.bot disconnected from ${sbServerAddress}:${sbServerPort}`)
SetErrorMessage(err);
}
});
function SetConnectionState(isConnected) {
if (isConnected) {
localStorage.setItem('sbServerAddress', sbAddressInput.value);
localStorage.setItem('sbServerPort', sbPortInput.value);
localStorage.setItem('sbServerPassword', sbPasswordInput.value);
sbConnectDialog.style.display = "none";
blurLayer.style.display = "none";
sbErrorLabel.style.display = 'none';
sbStatusIcon.src = 'icons/connected.svg';
sbStatusButton.title = `Connected to ${window.sbClient.info.name} (${window.sbClient.info.version})`;
// Check required actions
CheckRequiredActions();
}
else {
sbConnectDialog.style.display = "flex";
blurLayer.style.display = "block";
sbStatusIcon.src = 'icons/disconnected.svg';
SetErrorMessage('Disconnected from Streamer.bot');
}
}
function SetErrorMessage(error) {
sbErrorLabel.textContent = error;
sbErrorLabel.style.display = 'block';
}
////////////
// CONFIG //
////////////
if (configJson) {
// Set the header/title of the page
fetch(configJson)
.then(res => res.json())
.then(config => {
const root = document.documentElement;
const domain = getComputedStyle(root).getPropertyValue('--domain').trim();
// Set the page title
parent.document.title = `${domain}${config.title}`;
// Set the title label
title.textContent = config.title;
})
.catch(err => console.error('Failed to load config:', err));
}
async function CheckRequiredActions() {
if (configJson) {
console.debug('Checking required actions...')
// Set the header/title of the page
fetch(configJson)
.then(res => res.json())
.then(async config => {
// Clear the required actions list
const sbRequiredActionsList = document.getElementById('sb-required-actions-list');
sbRequiredActionsList.innerHTML = '';
// Set the import code
sbImportCodeLabel.textContent = config.sbImportCode;
// Assume all actions are found
SetRequiredActionState(true);
// Check each required action
if (config.requiredSbActions) {
// Get a full list of actions currently installed in Streamer.bot
const response = await sbClient.getActions();
// Iterate over required SB actions and check if they're present
config.requiredSbActions.forEach(req => {
const exists = response.actions.some(act => act.id === req.id);
console.debug(`${req.name}: ${exists ? 'Found' : 'Missing'}`)
// As soon as one is found that doesn't exist, throw up warning
if (!exists)
SetRequiredActionState(false);
// container div
const item = document.createElement('div');
item.className = 'sb-required-action';
// name label
const nameLabel = document.createElement('label');
nameLabel.textContent = req.name;
// status label
const statusLabel = document.createElement('label');
statusLabel.className = 'sb-required-action-found';
//statusLabel.textContent = exists ? '✅' : '❌';
statusLabel.textContent = exists ? 'Found' : 'Missing';
statusLabel.style.color = exists ? '#00d26a' : '#f92f60';
statusLabel.style.fontWeight = 500;
// append labels to div
item.appendChild(nameLabel);
item.appendChild(statusLabel);
// append div to list
sbRequiredActionsList.appendChild(item);
});
}
else
{
// There are no required actions, so hide the button
sbActionsButton.style.display = 'none';
}
})
.catch(err => console.error('Failed to load config:', err));
}
}
function SetRequiredActionState(isSuccess) {
if (isSuccess)
{
sbRequiredActionsSuccessLabel.style.display = 'block';
sbRequiredActionsFailureLabel.style.display = 'none';
sbRequiredActionsFailureSubtext.style.display = 'none';
sbActionsButton.title = `All actions found`;
sbActionsButton.textContent = '✅';
}
else
{
sbRequiredActionsSuccessLabel.style.display = 'none';
sbRequiredActionsFailureLabel.style.display = 'block';
sbRequiredActionsFailureSubtext.style.display = 'block';
sbActionsButton.title = `You are missing Streamer.bot actions`;
sbActionsButton.textContent = '⚠️';
OpenRequiredActionsDialog();
}
}
///////////////////////
// PAGE INTERACTIONS //
///////////////////////
function Connect() {
sbClient.options.host = sbAddressInput.value;
sbClient.options.port = sbPortInput.value;
sbClient.options.password = sbPasswordInput.value;
sbClient.connect();
}
function CopyImportCode() {
const textToCopy = sbImportCodeLabel.textContent;
navigator.clipboard.writeText(textToCopy)
.then(() => {
console.debug('Copied to clipboard!');
// Click feedback
const root = document.documentElement;
const successColor = getComputedStyle(root).getPropertyValue('--success-color').trim();
const buttonColor = getComputedStyle(root).getPropertyValue('--button-color').trim();
sbImportCopyButton.textContent = 'Copied!';
sbImportCopyButton.style.background = successColor;
setTimeout(() => {
sbImportCopyButton.textContent = 'Copy';
sbImportCopyButton.style.background = buttonColor;
}, 1500);
})
.catch(err => console.error('Failed to copy text: ', err));
}
function OpenConnectDialog() {
sbConnectDialog.style.display = "flex";
blurLayer.style.display = "block";
}
function OpenRequiredActionsDialog() {
sbRequiredActionsDialog.style.display = "flex";
blurLayer.style.display = "block";
}
function CloseRequiredActionsDialog() {
sbRequiredActionsDialog.style.display = "none";
blurLayer.style.display = "none";
}
+118
View File
@@ -0,0 +1,118 @@
html,
body {
height: 100%;
display: flex;
flex-direction: column;
}
/**************/
/*** HEADER ***/
/**************/
#header {
display: flex;
flex-direction: row;
align-items: center;
padding: 1em;
gap: 1em;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 1);
}
#header-end {
margin-left: auto;
align-items: center;
display: flex;
flex-direction: row;
gap: 1em;
}
#header-end button {
padding: 0.2em;
}
/*********************/
/*** PAGE CONTENTS ***/
/*********************/
#content {
flex: 1;
overflow: auto;
margin: 1em;
}
/******************/
/*** BLUR LAYER ***/
/******************/
#blur-layer {
position: fixed;
inset: 0;
/* top:0; left:0; bottom:0; right:0 */
backdrop-filter: blur(5px);
/* blur the content behind */
-webkit-backdrop-filter: blur(5px);
/* Safari support */
z-index: 1;
/* behind the modal */
}
/********************************/
/*** STREAMER.BOT CONNECT BOX ***/
/********************************/
#sb-required-actions-dialog {
max-width: 30em;
display: none;
}
#sb-connect-button:enabled:hover {
background-color: #3be477;
}
#sb-error-label {
background: #a82d2e33;
border-radius: 0.5em;
padding: 1em;
color: #c65e5e;
display: none;
}
/******************************************/
/*** STREAMER.BOT REQUIRED ACTIONS LIST ***/
/******************************************/
.sb-required-action {
display: flex;
flex-direction: row;
align-items: center;
}
.sb-required-action-found {
margin-left: auto;
}
#sb-import-code {
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
padding: 0.5em;
font-family: monospace;
font-size: 1em;
}
#sb-import-wrapper {
display: flex;
align-items: center;
gap: 0.5em;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

+278
View File
@@ -0,0 +1,278 @@
/*****************/
/*** VARIABLES ***/
/*****************/
:root {
--domain: nutty;
--background-color: #181818;
--accent-color: #2196f3;
--success-color: #3be477;
--button-color: #2e2e2e;
--dialog-background: #1d1d1d;
--callout-background: #181818;
}
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
html,
body {
margin: 0;
padding: 0;
font-family: Inter, system-ui, sans-serif;
color: white;
background-color: var(--background-color);
}
/************/
/*** TEXT ***/
/************/
.title {
font-weight: 900;
font-size: 1.5em;
text-transform: uppercase;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.setting-description {
font-size: 0.9em;
font-weight: 100;
}
/***************/
/*** DIVIDER ***/
/***************/
.divider {
border: none;
/* border-top: 0.1px solid #444444; */
margin: 0.3em 0;
}
/***************/
/*** BUTTONS ***/
/***************/
button {
font-size: 1em;
font-weight: 600;
background-color: var(--button-color);
color: white;
opacity: 0.8;
border-width: 0;
border-radius: 0.5em;
border: 1px solid #404040;
padding: 0.5em 1em;
width: 100%;
transition: all 0.2s ease-in-out;
}
button:hover {
opacity: 1;
cursor: pointer;
}
button:disabled {
opacity: 0.3;
cursor: inherit;
}
/************************/
/*** INPUT TEXT BOXES ***/
/************************/
input {
border-radius: 0.5em;
width: auto;
padding: 0.5em;
background-color: #171717;
border: 1px solid #404040;
color: white;
font-size: 0.9em;
}
input:disabled {
opacity: 0.5;
}
/***********************/
/*** SLIDER SWITCHES ***/
/***********************/
/* Switch styling */
.switch {
position: relative;
display: inline-block;
width: 3em;
height: 1.5em;
font-size: 1em;
overflow: hidden;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
}
.slider:before {
position: absolute;
content: "";
height: 1.1em;
width: 1.1em;
left: 0.2em;
top: 50%;
transform: translateY(-50%);
background-color: white;
transition: 0.4s;
}
input:checked+.slider {
background-color: var(--accent-color);
}
input:focus+.slider {
box-shadow: 0 0 1px var(--accent-color);
}
input:checked+.slider:before {
transform: translate(1.5em, -50%);
/* move knob across, stay centered */
}
/* Rounded sliders */
.slider.round {
border-radius: 1.5em;
}
.slider.round:before {
border-radius: 50%;
}
/******************/
/*** SCROLLBARS ***/
/******************/
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #2c2c2c;
/* Color of the tracking area */
}
::-webkit-scrollbar-thumb {
background-color: #9f9f9f;
/* Color of the scroll thumb */
border-radius: 4px;
/* Roundness of the scroll thumb */
border: none;
}
::-webkit-scrollbar-thumb:hover {
background-color: #d1d1d1;
/* Color of the scroll thumb on hover */
}
/***************/
/*** IFRAMES ***/
/***************/
iframe {
border-width: 0px;
}
/***************/
/*** CALLOUT ***/
/***************/
.callout {
font-size: 0.9em;
font-weight: 100;
background-color: var(--callout-background);
display: flex;
flex-direction: column;
gap: 1em;
padding: 1em;
border-radius: 0.5em;
border: 1px solid #40404080;
}
/**************/
/*** DIALOG ***/
/**************/
.dialog {
font-size: 1em;
background-color: var(--dialog-background);
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
gap: 1em;
padding: 1em;
border-radius: 0.5em;
border: 1px solid #40404080;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 1000; /* above overlay */
}
.dialog-nav-button {
background: transparent;
border: none;
font-size: 1.5em;
font-weight: 100;
padding: 0em;
width: 1em;
height: 1em;
position: absolute;
position: fixed; /* fixed to viewport */
top: 0.5em; /* small offset from top */
right: 0.5em; /* small offset from right */
}
.dialog-nav-button:hover {
background: var(--button-color);
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="../../../.common/styles/global.css" />
<link rel="stylesheet" href="./style.css" />
<script src="https://cdn.jsdelivr.net/npm/luxon@3/build/global/luxon.min.js"></script>
</head>
<body>
<div id="settings">
<div class="setting">
<div>
<div class="setting-label" for="printer-name">Printer Name</div>
<div class="setting-description">As labelled in Windows > Settings > Printers & Scanners</div>
</div>
<input type="text" id="printer-name" name="printer-name" placeholder="" value="" autocomplete="off">
</div>
<div class="setting">
<div>
<label class="setting-label" for="paper-width">Paper Width (mm)</label>
<div class="setting-description">Printer Bot was designed for 80mm thermal paper</div>
</div>
<input type="number" id="paper-width" name="paper-width" placeholder="80" value="80" min="0" autocomplete="off">
</div>
<div class="setting">
<div>
<label class="setting-label" for="ignore-test-triggers">Ignore Test Triggers</label>
<div class="setting-description">In enabled, 'Test' and 'Simulate' events will be ignored</div>
</div>
<label class="switch" style="margin-left: auto;">
<input type="checkbox" id="ignore-test-triggers" name="ignore-test-triggers">
<span class="slider round"></span>
</label>
</div>
<div class="setting">
<div>
<label class="setting-label" for="delete-temp-files">Delete Temp Files</label>
<div class="setting-description">Turn this off for debugging</div>
</div>
<label class="switch" style="margin-left: auto;">
<input type="checkbox" id="delete-temp-files" name="delete-temp-files" checked>
<span class="slider round"></span>
</label>
</div>
<button id="test-print-button" onclick="TestPrint()">Test Print</button>
</div>
</body>
</html>
<!-- Receipt Template -->
<template id="receipt-template">
<div id="receipt-container">
<!-- Header -->
<div id="receipt-header">
<img id="receipt-avatar">
<div>
<div id="receipt-title"></div>
<div id="receipt-subtitle"></div>
</div>
</div>
<!-- Contents -->
<div id="receipt-content">
</div>
<!-- Footer -->
<div id="receipt-footer">
<div id="receipt-date"></div>
<img id="receipt-icon" src="icons/platforms/twitch.png">
</div>
</div>
</template>
<script src="script.js"></script>
+790
View File
@@ -0,0 +1,790 @@
//////////////////////
// GLOBAL VARIABLES //
//////////////////////
const sbActionPrintRoutine = '5c756513-a1d0-4285-9dbc-21ad34491310';
const avatarMap = new Map();
/////////////////////////
// STREAMER.BOT EVENTS //
/////////////////////////
window.parent.sbClient.on('General.Custom', (response) => {
console.debug(response.data);
CustomEvent(response.data);
})
/////////////////
// PRINTER BOT //
/////////////////
async function CustomEvent(data) {
if (data.actionName != 'Printer Bot | Events')
return;
// Get a reference to the template
const template = document.getElementById('receipt-template');
// Create a new instance of the template
const instance = template.content.cloneNode(true);
// Get divs
const headerEl = instance.querySelector('#receipt-header');
const contentEl = instance.querySelector('#receipt-content');
const footerEl = instance.querySelector('#receipt-footer');
const avatarEl = instance.querySelector('#receipt-avatar');
const titleEl = instance.querySelector('#receipt-title');
const subtitleEl = instance.querySelector('#receipt-subtitle');
const iconEl = instance.querySelector('#receipt-icon');
const dateEl = instance.querySelector('#receipt-date');
// Set the main contents
switch (data.__source) {
// Twitch events
case ('TwitchCheer'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
titleEl.innerText = `${data.bits} BITS`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = data.message;
// Render emotes
for (i in data.emotes) {
const emoteElement = `<img src="${data.emotes[i].imageUrl}" class="emote"/>`;
const emoteName = EscapeRegExp(data.emotes[i].name);
let regexPattern = emoteName;
// Check if the emote name consists only of word characters (alphanumeric and underscore)
if (/^\w+$/.test(emoteName)) {
regexPattern = `\\b${emoteName}\\b`;
}
else {
// For non-word emotes, ensure they are surrounded by non-word characters or boundaries
regexPattern = `(?<=^|[^\\w])${emoteName}(?=$|[^\\w])`;
}
const regex = new RegExp(regexPattern, 'g');
messageEl.innerHTML = messageEl.innerHTML.replace(regex, emoteElement);
}
// Render cheermotes
for (i in data.cheerEmotes) {
const bits = data.cheerEmotes[i].bits;
const imageUrl = data.cheerEmotes[i].imageUrl;
const name = data.cheerEmotes[i].name;
const cheerEmoteElement = `<img src="${imageUrl}" class="emote"/>`;
const bitsElements = `<span class="bits">${bits}</span>`
messageEl.innerHTML = messageEl.innerHTML.replace(new RegExp(`\\b${name}${bits}\\b`, 'i'), cheerEmoteElement + bitsElements);
}
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchSub'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
titleEl.innerText = `${data.tier} subscriber`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = '<b>First time subscriber!</b>';
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchReSub'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
titleEl.innerText = `${data.tier} subscriber`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.cumulative} months${data.monthStreak > 1 ? ' (' + data.monthStreak + ' in a row!)' : ''}</b>`;
if (data.messageStripped)
messageEl.innerHTML += `<br><br><i>${data.messageStripped}</i>`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchGiftSub'):
{
// Don't put profile pictures for subs that come from Gift Bombs to avoid rate limits
if (data.fromGiftBomb)
//avatarEl.style.display = 'none';
return;
else
avatarEl.src = await GetAvatar(data.recipientUserName, 'twitch');
titleEl.innerText = `Gifted Sub`;
const messageEl = document.createElement('div');
messageEl.innerHTML += `<b>${data.recipientUser}</b><br>received a ${data.tier} sub from<br>`;
if (data.anonymous)
messageEl.innerHTML += `a mysterious admirer...`;
else
messageEl.innerHTML += `<b>${data.user}</b>!`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchGiftBomb'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
titleEl.innerHTML = `${data.gifts} × Gifted Subs`;
subtitleEl.innerHTML += `✧*・゚✧ ${data.tier.toUpperCase()} ✧・゚*✧`;
if (data.anonymous)
subtitleEl.innerHTML += `<br>From a mystery person...`;
else
subtitleEl.innerHTML += `<br>${data.user}`;
const messageEl = document.createElement('div');
if (data.totalGifts > 1) {
messageEl.innerHTML = `They've gifted <b>${data.totalGifts} subs</b> in total!</br></br>`;
}
// Get a list of all recipient users
Object.keys(data)
.filter(key => /^gift\.recipientUser\d+$/.test(key))
.forEach((key, index) => {
const username = data[key];
messageEl.innerHTML += `${username}</br>`;
});
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchRaid'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>is raiding with a party of<br><b>${data.viewers} viewers!</b>`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
// YouTube Events
case ('YouTubeNewSponsor'):
{
if (data.userProfileUrl)
avatarEl.src = data.userProfileUrl;
else
avatarEl.style.display = 'none';
titleEl.innerText = `${data.levelName}`;
subtitleEl.innerText = `${data.user}`;
contentEl.style.display = 'none';
// Set the platform icon
SetPlatformIcon(iconEl, 'youtube');
}
break;
case ('YouTubeGiftMembershipReceived'):
{
if (data.gifterProfileUrl)
avatarEl.src = data.gifterProfileUrl;
else
avatarEl.style.display = 'none';
titleEl.innerText = `Gifted Membership`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>received a membership from<br><b>${data.gifterUser}</b>!`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'youtube');
}
break;
case ('YouTubeSuperChat'):
{
if (data.userProfileUrl)
avatarEl.src = data.userProfileUrl;
else
avatarEl.style.display = 'none';
titleEl.style.fontSize = '2em';
titleEl.innerText = `${data.amount}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>sent a Super Chat!`;
if (data.message)
messageEl.innerHTML += `<br><br><i>${data.message}</i>`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'youtube');
}
break;
case ('YouTubeSuperSticker'):
{
if (data.stickerImageUrl)
avatarEl.src = data.stickerImageUrl;
else
avatarEl.style.display = 'none';
titleEl.style.fontSize = '2em';
titleEl.innerText = `${data.amount}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>sent a Super Sticker!`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'youtube');
}
break;
break;
// Kick Events
case ('KickSubscription'):
case ('KickResubscription'):
{
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data.user, 'kick'));
titleEl.innerText = `Subscriber`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
if (data.duration > 1)
messageEl.innerHTML = `<b>${data.duration} months</b>`;
else
messageEl.innerHTML = '<b>First time subscriber!</b>';
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'kick');
}
break;
case ('KickGiftSubscription'):
{
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data["recipient.userLogin"], 'kick'));
titleEl.innerText = `Gifted Sub`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data["recipient.userName"]}</b><br>received a sub from<br><b>${data.user}</b>!`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'kick');
}
break;
case ('KickMassGiftSubscription'):
{
// There is only one sub, so use the same template for a single gifted sub
if ('recipient.userName' in data) {
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data["recipient.userLogin"], 'kick'));
titleEl.innerText = `Gifted Sub`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data["recipient.userName"]}</b><br>received a sub from<br><b>${data.user}</b>!`;
contentEl.appendChild(messageEl);
}
else {
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data.user, 'kick'));
// Calculate how many subs were gived
let maxIndex = -1;
for (const key in data) {
const match = key.match(/^recipient\.(\d+)\./);
if (match) {
const index = parseInt(match[1], 10);
if (index > maxIndex) {
maxIndex = index;
}
}
}
const totalGifts = maxIndex + 1;
titleEl.innerHTML = `${totalGifts} × Gifted Subs`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
// Loop through each recipient and include it in the receipt
const recipients = {};
// Reconstruct recipient objects
for (const key in data) {
const match = key.match(/^recipient\.(\d+)\.(.+)$/);
if (match) {
const index = match[1];
const field = match[2];
if (!recipients[index]) {
recipients[index] = {};
}
recipients[index][field] = data[key];
}
}
// Loop through and print userName
for (const index in recipients) {
messageEl.innerHTML += `${recipients[index].userName}<br>`;
}
contentEl.appendChild(messageEl);
}
// Set the platform icon
SetPlatformIcon(iconEl, 'kick');
}
break;
// StreamElements Events
case ('StreamElementsTip'):
{
const avatarURL = await GetAvatar(data.tipUsername, 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.style.fontSize = '2em';
titleEl.innerText = FormatCurrency(data.tipAmount, data.tipCurrency);
subtitleEl.innerText = `${data.tipUsername}`;
if (data.tipMessage) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<i>${data.tipMessage}</i>`;
contentEl.appendChild(messageEl);
}
else {
contentEl.style.display = 'none';
}
}
break;
// Streamlabs Events
case ('StreamlabsDonation'):
{
const avatarURL = await GetAvatar(data.donationFrom, 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.style.fontSize = '2em';
titleEl.innerText = data.donationFormattedAmount;
subtitleEl.innerText = `${data.donationFrom}`;
if (data.donationMessage) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<i>${data.donationMessage}</i>`;
contentEl.appendChild(messageEl);
}
else {
contentEl.style.display = 'none';
}
}
break;
// Fourthwall Events
case ('FourthwallDonation'):
{
const avatarURL = await GetAvatar(data["fw.username"], 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.style.fontSize = '2em';
titleEl.innerText = FormatCurrency(data["fw.amount"], data["fw.currency"]);
if (data["fw.username"])
subtitleEl.innerText = `${data["fw.username"]}`;
else if (data["fw.email"])
subtitleEl.innerText = `${data["fw.email"]}`;
if (data["fw.message"]) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<i>${data["fw.message"]}</i>`;
contentEl.appendChild(messageEl);
}
else {
contentEl.style.display = 'none';
}
}
break;
// case ('FourthwallGiftPurchase'):
// break;
case ('FourthwallOrderPlaced'):
{
// Only print non-free orders
if (data["fw.total"] <= 0)
return;
const avatarURL = await GetAvatar(data["fw.username"], 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.style.fontSize = '2em';
titleEl.innerText = FormatCurrency(data["fw.total"], data["fw.currency"]);
if (data["fw.username"])
subtitleEl.innerText = `${data["fw.username"]}`;
else if (data["fw.email"])
subtitleEl.innerText = `${data["fw.email"]}`;
// Compile a list of all items bought
const variants = [];
// Iterate through all keys in the data object
for (const key in data) {
const match = key.match(/^fw\.variants\[(\d+)\]\.(\w+)$/);
if (match) {
const index = Number(match[1]);
const field = match[2];
// Make sure the array slot exists
if (!variants[index]) {
variants[index] = {};
}
// Assign the field to the appropriate variant object
variants[index][field] = data[key];
}
}
// Print each item on the receipt
const messageEl = document.createElement('div');
variants.forEach((variant, i) => {
messageEl.innerHTML += `${variant.quantity} × ${variant.name}<br>`;
});
messageEl.style.textAlign = 'left';
// Check if they left a custom message
let customMessageEl = document.createElement('div');
const customMessage = data["fw.statmessageus"];
if (customMessage) {
const txt = document.createElement("textarea");
txt.innerHTML = customMessage;
customMessageEl.innerHTML += `<br><i>${txt.value}</i>`;
}
// Add a cute thank you message because you're uwu like that
const thankYouEl = document.createElement('div');
thankYouEl.innerHTML += `<br><b>Thank you for your purchase!</b>`;
contentEl.appendChild(messageEl);
contentEl.appendChild(customMessageEl);
contentEl.appendChild(thankYouEl);
}
break;
case ('FourthwallSubscriptionPurchased'):
{
const avatarURL = await GetAvatar(data["fw.nickname"], 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.innerText = `New Member`;
subtitleEl.innerHTML = `${data["fw.nickname"]}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `Thanks for joining at the <b>${FormatCurrency(data["fw.amount"], data["fw.currency"])}</b> tier!`;
contentEl.appendChild(messageEl);
}
break;
// Custom Code Events
case ('CustomCodeEvent'):
{
switch (data.triggerCustomCodeEventName) {
case ('kickIncomingRaid'):
{
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data.user, 'kick'));
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>is hosting with a party of<br><b>${data.viewers} viewers!</b>`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'kick');
}
break;
}
}
break;
// Don't print any event not excplicitly listed above
default:
return;
}
// Set the timestamp
const { DateTime } = luxon;
const now = DateTime.local();
const formatted = now.toFormat("cccc, LLLL d',' yyyy HH:mm:ss");
// Add ordinal suffix manually
function addOrdinal(n) {
if (n >= 11 && n <= 13) return 'th';
switch (n % 10) {
case 1: return 'st';
case 2: return 'nd';
case 3: return 'rd';
default: return 'th';
}
}
const day = now.day;
const ordinal = addOrdinal(day);
const fullFormatted = now.toFormat(`cccc, LLLL '${day}${ordinal}' yyyy HH:mm:ss`);
dateEl.textContent = fullFormatted;
// Send it to the print routine!
const receiptHTML = await GetRenderedHTML(instance);
window.parent.sbClient.doAction({ id: sbActionPrintRoutine }, {
receiptHTML: receiptHTML,
isTest: data.isTest,
printerName: document.getElementById('printer-name').value,
paperWidth: document.getElementById('paper-width').value,
ignoreTestTriggers: document.getElementById('ignore-test-triggers').checked,
deleteTempFiles: document.getElementById('delete-temp-files').checked
});
}
//////////////////////
// HELPER FUNCTIONS //
//////////////////////
async function GetAvatar(username, platform) {
// First, check if the username is hashed already
if (avatarMap.has(`${username}-${platform}`)) {
console.debug(`Avatar found for ${username} (${platform}). Retrieving from hash map.`)
return avatarMap.get(`${username}-${platform}`);
}
// If code reaches this point, the username hasn't been hashed, so retrieve avatar
switch (platform) {
case 'twitch':
{
console.debug(`No avatar found for ${username} (${platform}). Retrieving from Decapi.`)
let response = await fetch('https://decapi.me/twitch/avatar/' + username);
let data = await response.text();
avatarMap.set(`${username}-${platform}`, data);
return data;
}
case 'kick':
{
console.debug(`No avatar found for ${username} (${platform}). Retrieving from Kick.`)
try {
let response = await fetch('https://kick.com/api/v2/channels/' + username);
console.log('https://kick.com/api/v2/channels/' + username)
let data = await response.json();
let avatarURL = data.user.profile_pic;
if (!avatarURL)
avatarURL = 'https://kick.com/img/default-profile-pictures/default2.jpeg';
avatarMap.set(`${username}-${platform}`, avatarURL);
return avatarURL;
}
catch (error) {
console.debug(error);
return 'https://kick.com/img/default-profile-pictures/default2.jpeg';
}
}
}
}
async function GetRenderedHTML(fragment) {
if (!(fragment instanceof DocumentFragment)) {
throw new Error('Argument must be a DocumentFragment');
}
// Filter out comment nodes from fragment content
const nodes = Array.from(fragment.childNodes).filter(
node => node.nodeType !== Node.COMMENT_NODE
);
const bodyContent = nodes
.map(node => node.outerHTML || node.textContent)
.join('');
// Get inline <style> contents
const inlineStyles = Array.from(document.querySelectorAll('style'))
.map(style => style.textContent)
.join('\n');
// Get all external stylesheet URLs
const linkHrefs = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
.map(link => link.href);
// Fetch external CSS contents
const externalCSSContents = await Promise.all(
linkHrefs.map(async href => {
try {
const res = await fetch(href);
if (!res.ok) throw new Error(`Failed to load CSS from ${href}`);
return await res.text();
} catch {
console.warn(`Could not fetch CSS from ${href}`);
return '';
}
})
);
// Combine all CSS into one string
const combinedCSS = inlineStyles + '\n' + externalCSSContents.join('\n');
// Build the full standalone HTML string
const fullHTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>${combinedCSS}</style>
</head>
<body>
${bodyContent}
</body>
</html>`.trim();
return fullHTML;
}
function ConvertWEBPToPNG(URL) {
return `https://images.weserv.nl/?url=${URL}&output=png`;
}
function FormatCurrency(amount, currency) {
const isISOCode = /^[A-Z]{3}$/.test(currency);
if (isISOCode) {
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency,
currencyDisplay: 'symbol',
}).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
// Handle some common symbols that go before the number
const symbolsBefore = ['$', '€', '£', '¥', '₹'];
if (symbolsBefore.includes(currency)) {
return `${currency}${amount.toFixed(2)}`;
}
// Otherwise default to appending after
return `${amount.toFixed(2)} ${currency}`;
}
function IsValidUrl(string) {
try {
new URL(string);
return true;
} catch {
return false;
}
}
function EscapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
function SetPlatformIcon(el, platform) {
// Set the platform icon
let baseURL = window.location.href;
baseURL = baseURL.replace(/index\.html$/i, '');
el.src = `${baseURL}/icons/platforms/${platform}.png`;
}
///////////////////////
// PAGE INTERACTIONS //
///////////////////////
let data = {
"__source": "TwitchSub",
"tier": "prime",
"isPrimeSub": true,
"monthsSubscribed": 1,
"isTest": false,
"actionName": "Printer Bot | Events",
"user": "nutty",
"userName": "nutty",
"userType": "twitch"
}
async function TestPrint() {
CustomEvent(data);
}
///////////////////
// PAGE SETTINGS //
///////////////////
// Get references
const printerNameInput = document.getElementById('printer-name');
const paperWidthInput = document.getElementById('paper-width');
const ignoreTestTriggersInput = document.getElementById('ignore-test-triggers');
const deleteTempFilesInput = document.getElementById('delete-temp-files');
// Local storage key must be prefixed with the first URL segment
const currentPath = window.location.pathname;
const urlSegment = currentPath.split('/').filter(Boolean)[0];
const storageKey = (id) => `${urlSegment}::${id}`;
function saveSetting(id) {
const el = document.getElementById(id);
const value = el.type === "checkbox" ? el.checked : el.value;
localStorage.setItem(storageKey(id), value);
}
// Add event listeners
[printerNameInput, paperWidthInput, ignoreTestTriggersInput, deleteTempFilesInput].forEach(input => {
input.addEventListener("input", () => saveSetting(input.id));
input.addEventListener("change", () => saveSetting(input.id));
});
// Load settings
if (localStorage.getItem(storageKey(printerNameInput.id)))
printerNameInput.value = localStorage.getItem(storageKey(printerNameInput.id));
if (localStorage.getItem(storageKey(paperWidthInput.id)))
paperWidthInput.value = localStorage.getItem(storageKey(paperWidthInput.id));
if (localStorage.getItem(storageKey(ignoreTestTriggersInput.id)))
ignoreTestTriggersInput.checked = JSON.parse(localStorage.getItem(storageKey(ignoreTestTriggersInput.id)));
if (localStorage.getItem(storageKey(deleteTempFilesInput.id)))
deleteTempFilesInput.checked = JSON.parse(localStorage.getItem(storageKey(deleteTempFilesInput.id)));
+68
View File
@@ -0,0 +1,68 @@
#settings {
display: flex;
flex-direction: column;
gap: 1em;
}
.setting input {
margin-left: auto;
width: 15em;
}
.setting {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5em;
}
/************************/
/*** RECEIPT TEMPLATE ***/
/************************/
#receipt-container {
text-align: center;
color: black;
}
#receipt-header {}
#receipt-content {
padding: 0.5em 0em;
}
#receipt-footer {}
#receipt-avatar {
width: 6em;
height: 6em;
border-radius: 50%;
object-fit: cover;
}
#receipt-title {
font-weight: 900;
font-size: 1.5em;
text-transform: uppercase;
}
#receipt-subtitle {
font-weight: 700;
font-size: 1.2em;
}
#receipt-icon {
height: 1em;
}
#receipt-date {
margin: 0.5em 0em;
font-size: 0.7em;
text-transform: uppercase;
}
.emote {
height: 1em;
}
+13 -42
View File
@@ -1,49 +1,20 @@
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<link rel="shortcut icon" href="#" />
<link rel="stylesheet" href="./style.css" />
<script type="text/javascript" src="https://unpkg.com/@streamerbot/client/dist/streamerbot-client.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3/build/global/luxon.min.js"></script>
<meta charset="UTF-8">
<link rel="stylesheet" href="../.common/styles/global.css" />
<link rel="icon" href="../../.resources/logo.png" type="image/png">
<title>nutty</title>
<style>
#dock-wrapper {
width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
<div id="background">
<div id="connect-box">
<div class="field">
<label for="ip">IP Address</label>
<input type="text" id="ip" name="ip" placeholder="127.0.0.1" value="127.0.0.1">
</div>
<div class="field">
<label for="port">Port</label>
<input type="text" id="port" name="port" placeholder="8080" value="8080">
</div>
<button id="connect-button" onclick="Connect()">Connect</button>
</div>
</div>
<iframe id="dock-wrapper"></iframe>
<script src="script.js"></script>
</body>
<template id="receipt-template">
<!-- Header -->
<div id="header">
<img id="avatar">
<div>
<div id="title"></div>
<div id="subtitle"></div>
</div>
</div>
<!-- Contents -->
<div id="content">
</div>
<!-- Footer -->
<div id="footer">
<div id="date"></div>
<img id="icon" src="icons/platforms/twitch.png">
</div>
</template>
<script src="./script.js"></script>
</html>
+11 -799
View File
@@ -1,804 +1,16 @@
///////////////////
// PAGE ELEMENTS //
///////////////////
// Construct URL
const currentURL = window.location.href;
let baseURL = currentURL;
const headerEl = document.getElementById('header');
const contentEl = document.getElementById('content');
const footerEl = document.getElementById('footer');
if (baseURL.endsWith("index.html"))
baseURL = baseURL.replace("index.html", "");
const avatarEl = document.getElementById('avatar');
const titleEl = document.getElementById('title');
const subtitleEl = document.getElementById('subtitle');
const dateEl = document.getElementById('date');
const configJson = "?config=" + baseURL + "config.json";
// Implement widget dock core
window.dockWrapper = document.getElementById('dock-wrapper');
dockWrapper.src = `../../.common/core/widget-dock-core${configJson}`;
//////////////////////
// GLOBAL VARIABLES //
//////////////////////
const avatarMap = new Map();
/////////////////////////
// STREAMER.BOT CLIENT //
/////////////////////////
// Check local storage
if (localStorage.getItem('sbServerAddress') === null)
localStorage.setItem('sbServerAddress', '127.0.0.1');
if (localStorage.getItem('sbServerPort') === null)
localStorage.setItem('sbServerPort', '8080');
document.getElementById('ip').value = localStorage.getItem('sbServerAddress');
document.getElementById('port').value = localStorage.getItem('sbServerPort');
let sbServerAddress = document.getElementById('ip').value;
let sbServerPort = document.getElementById('port').value;
let client = new StreamerbotClient({
host: sbServerAddress,
port: sbServerPort,
onConnect: (data) => {
console.log(`Streamer.bot successfully connected to ${sbServerAddress}:${sbServerPort}`)
console.debug(data);
SetConnectionState(true);
},
onDisconnect: () => {
console.error(`Streamer.bot disconnected from ${sbServerAddress}:${sbServerPort}`)
SetConnectionState(false);
}
dockWrapper.addEventListener('load', () => {
dockWrapper.contentWindow.content.src = window.location.href + '/contents';
});
client.on('General.Custom', (response) => {
console.debug(response.data);
CustomEvent(response.data);
})
////////////////////
// STREAM PRINTER //
////////////////////
async function CustomEvent(data) {
if (data.actionName != 'Printer Bot | Events')
return;
// Get a reference to the template
const template = document.getElementById('receipt-template');
// Create a new instance of the template
const instance = template.content.cloneNode(true);
// Get divs
const headerEl = instance.querySelector('#header');
const contentEl = instance.querySelector('#content');
const footerEl = instance.querySelector('#footer');
const avatarEl = instance.querySelector('#avatar');
const titleEl = instance.querySelector('#title');
const subtitleEl = instance.querySelector('#subtitle');
const iconEl = instance.querySelector('#icon');
const dateEl = instance.querySelector('#date');
// Set the main contents
switch (data.__source) {
// Twitch events
case ('TwitchCheer'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
titleEl.innerText = `${data.bits} BITS`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = data.message;
// Render emotes
for (i in data.emotes) {
const emoteElement = `<img src="${data.emotes[i].imageUrl}" class="emote"/>`;
const emoteName = EscapeRegExp(data.emotes[i].name);
let regexPattern = emoteName;
// Check if the emote name consists only of word characters (alphanumeric and underscore)
if (/^\w+$/.test(emoteName)) {
regexPattern = `\\b${emoteName}\\b`;
}
else {
// For non-word emotes, ensure they are surrounded by non-word characters or boundaries
regexPattern = `(?<=^|[^\\w])${emoteName}(?=$|[^\\w])`;
}
const regex = new RegExp(regexPattern, 'g');
messageEl.innerHTML = messageEl.innerHTML.replace(regex, emoteElement);
}
// Render cheermotes
for (i in data.cheerEmotes) {
const bits = data.cheerEmotes[i].bits;
const imageUrl = data.cheerEmotes[i].imageUrl;
const name = data.cheerEmotes[i].name;
const cheerEmoteElement = `<img src="${imageUrl}" class="emote"/>`;
const bitsElements = `<span class="bits">${bits}</span>`
messageEl.innerHTML = messageEl.innerHTML.replace(new RegExp(`\\b${name}${bits}\\b`, 'i'), cheerEmoteElement + bitsElements);
}
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchSub'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
titleEl.innerText = `${data.tier} subscriber`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = '<b>First time subscriber!</b>';
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchReSub'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
titleEl.innerText = `${data.tier} subscriber`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.cumulative} months${data.monthStreak > 1 ? ' (' + data.monthStreak + ' in a row!)' : ''}</b>`;
if (data.messageStripped)
messageEl.innerHTML += `<br><br><i>${data.messageStripped}</i>`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchGiftSub'):
{
// Don't put profile pictures for subs that come from Gift Bombs to avoid rate limits
if (data.fromGiftBomb)
//avatarEl.style.display = 'none';
return;
else
avatarEl.src = await GetAvatar(data.recipientUserName, 'twitch');
titleEl.innerText = `Gifted Sub`;
const messageEl = document.createElement('div');
messageEl.innerHTML += `<b>${data.recipientUser}</b><br>received a ${data.tier} sub from<br>`;
if (data.anonymous)
messageEl.innerHTML += `a mysterious admirer...`;
else
messageEl.innerHTML += `<b>${data.user}</b>!`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchGiftBomb'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
titleEl.innerHTML = `${data.gifts} × Gifted Subs`;
subtitleEl.innerHTML += `✧*・゚✧ ${data.tier.toUpperCase()} ✧・゚*✧`;
if (data.anonymous)
subtitleEl.innerHTML += `<br>From a mystery person...`;
else
subtitleEl.innerHTML += `<br>${data.user}`;
const messageEl = document.createElement('div');
if (data.totalGifts > 1) {
messageEl.innerHTML = `They've gifted <b>${data.totalGifts} subs</b> in total!</br></br>`;
}
// Get a list of all recipient users
Object.keys(data)
.filter(key => /^gift\.recipientUser\d+$/.test(key))
.forEach((key, index) => {
const username = data[key];
messageEl.innerHTML += `${username}</br>`;
});
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
case ('TwitchRaid'):
{
avatarEl.src = await GetAvatar(data.userName, 'twitch');
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>is raiding with a party of<br><b>${data.viewers} viewers!</b>`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'twitch');
}
break;
// YouTube Events
case ('YouTubeNewSponsor'):
{
if (data.userProfileUrl)
avatarEl.src = data.userProfileUrl;
else
avatarEl.style.display = 'none';
titleEl.innerText = `${data.levelName}`;
subtitleEl.innerText = `${data.user}`;
contentEl.style.display = 'none';
// Set the platform icon
SetPlatformIcon(iconEl, 'youtube');
}
break;
case ('YouTubeGiftMembershipReceived'):
{
if (data.gifterProfileUrl)
avatarEl.src = data.gifterProfileUrl;
else
avatarEl.style.display = 'none';
titleEl.innerText = `Gifted Membership`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>received a membership from<br><b>${data.gifterUser}</b>!`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'youtube');
}
break;
case ('YouTubeSuperChat'):
{
if (data.userProfileUrl)
avatarEl.src = data.userProfileUrl;
else
avatarEl.style.display = 'none';
titleEl.style.fontSize = '2em';
titleEl.innerText = `${data.amount}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>sent a Super Chat!`;
if (data.message)
messageEl.innerHTML += `<br><br><i>${data.message}</i>`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'youtube');
}
break;
case ('YouTubeSuperSticker'):
{
if (data.stickerImageUrl)
avatarEl.src = data.stickerImageUrl;
else
avatarEl.style.display = 'none';
titleEl.style.fontSize = '2em';
titleEl.innerText = `${data.amount}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>sent a Super Sticker!`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'youtube');
}
break;
break;
// Kick Events
case ('KickSubscription'):
case ('KickResubscription'):
{
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data.user, 'kick'));
titleEl.innerText = `Subscriber`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
if (data.duration > 1)
messageEl.innerHTML = `<b>${data.duration} months</b>`;
else
messageEl.innerHTML = '<b>First time subscriber!</b>';
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'kick');
}
break;
case ('KickGiftSubscription'):
{
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data["recipient.userLogin"], 'kick'));
titleEl.innerText = `Gifted Sub`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data["recipient.userName"]}</b><br>received a sub from<br><b>${data.user}</b>!`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'kick');
}
break;
case ('KickMassGiftSubscription'):
{
// There is only one sub, so use the same template for a single gifted sub
if ('recipient.userName' in data) {
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data["recipient.userLogin"], 'kick'));
titleEl.innerText = `Gifted Sub`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data["recipient.userName"]}</b><br>received a sub from<br><b>${data.user}</b>!`;
contentEl.appendChild(messageEl);
}
else {
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data.user, 'kick'));
// Calculate how many subs were gived
let maxIndex = -1;
for (const key in data) {
const match = key.match(/^recipient\.(\d+)\./);
if (match) {
const index = parseInt(match[1], 10);
if (index > maxIndex) {
maxIndex = index;
}
}
}
const totalGifts = maxIndex + 1;
titleEl.innerHTML = `${totalGifts} × Gifted Subs`;
subtitleEl.innerText = `${data.user}`;
const messageEl = document.createElement('div');
// Loop through each recipient and include it in the receipt
const recipients = {};
// Reconstruct recipient objects
for (const key in data) {
const match = key.match(/^recipient\.(\d+)\.(.+)$/);
if (match) {
const index = match[1];
const field = match[2];
if (!recipients[index]) {
recipients[index] = {};
}
recipients[index][field] = data[key];
}
}
// Loop through and print userName
for (const index in recipients) {
messageEl.innerHTML += `${recipients[index].userName}<br>`;
}
contentEl.appendChild(messageEl);
}
// Set the platform icon
SetPlatformIcon(iconEl, 'kick');
}
break;
// StreamElements Events
case ('StreamElementsTip'):
{
const avatarURL = await GetAvatar(data.tipUsername, 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.style.fontSize = '2em';
titleEl.innerText = FormatCurrency(data.tipAmount, data.tipCurrency);
subtitleEl.innerText = `${data.tipUsername}`;
if (data.tipMessage) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<i>${data.tipMessage}</i>`;
contentEl.appendChild(messageEl);
}
else {
contentEl.style.display = 'none';
}
}
break;
// Streamlabs Events
case ('StreamlabsDonation'):
{
const avatarURL = await GetAvatar(data.donationFrom, 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.style.fontSize = '2em';
titleEl.innerText = data.donationFormattedAmount;
subtitleEl.innerText = `${data.donationFrom}`;
if (data.donationMessage) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<i>${data.donationMessage}</i>`;
contentEl.appendChild(messageEl);
}
else {
contentEl.style.display = 'none';
}
}
break;
// Fourthwall Events
case ('FourthwallDonation'):
{
const avatarURL = await GetAvatar(data["fw.username"], 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.style.fontSize = '2em';
titleEl.innerText = FormatCurrency(data["fw.amount"], data["fw.currency"]);
if (data["fw.username"])
subtitleEl.innerText = `${data["fw.username"]}`;
else if (data["fw.email"])
subtitleEl.innerText = `${data["fw.email"]}`;
if (data["fw.message"]) {
const messageEl = document.createElement('div');
messageEl.innerHTML = `<i>${data["fw.message"]}</i>`;
contentEl.appendChild(messageEl);
}
else {
contentEl.style.display = 'none';
}
}
break;
// case ('FourthwallGiftPurchase'):
// break;
case ('FourthwallOrderPlaced'):
{
// Only print non-free orders
if (data["fw.total"] <= 0)
return;
const avatarURL = await GetAvatar(data["fw.username"], 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.style.fontSize = '2em';
titleEl.innerText = FormatCurrency(data["fw.total"], data["fw.currency"]);
if (data["fw.username"])
subtitleEl.innerText = `${data["fw.username"]}`;
else if (data["fw.email"])
subtitleEl.innerText = `${data["fw.email"]}`;
// Compile a list of all items bought
const variants = [];
// Iterate through all keys in the data object
for (const key in data) {
const match = key.match(/^fw\.variants\[(\d+)\]\.(\w+)$/);
if (match) {
const index = Number(match[1]);
const field = match[2];
// Make sure the array slot exists
if (!variants[index]) {
variants[index] = {};
}
// Assign the field to the appropriate variant object
variants[index][field] = data[key];
}
}
// Print each item on the receipt
const messageEl = document.createElement('div');
variants.forEach((variant, i) => {
messageEl.innerHTML += `${variant.quantity} × ${variant.name}<br>`;
});
messageEl.style.textAlign = 'left';
// Check if they left a custom message
let customMessageEl = document.createElement('div');
const customMessage = data["fw.statmessageus"];
if (customMessage)
{
const txt = document.createElement("textarea");
txt.innerHTML = customMessage;
customMessageEl.innerHTML += `<br><i>${txt.value}</i>`;
}
// Add a cute thank you message because you're uwu like that
const thankYouEl = document.createElement('div');
thankYouEl.innerHTML += `<br><b>Thank you for your purchase!</b>`;
contentEl.appendChild(messageEl);
contentEl.appendChild(customMessageEl);
contentEl.appendChild(thankYouEl);
}
break;
case ('FourthwallSubscriptionPurchased'):
{
const avatarURL = await GetAvatar(data["fw.nickname"], 'twitch');
if (IsValidUrl(avatarURL))
avatarEl.src = avatarURL;
else
avatarEl.style.display = 'none'
titleEl.innerText = `New Member`;
subtitleEl.innerHTML = `${data["fw.nickname"]}`;
const messageEl = document.createElement('div');
messageEl.innerHTML = `Thanks for joining at the <b>${FormatCurrency(data["fw.amount"], data["fw.currency"])}</b> tier!`;
contentEl.appendChild(messageEl);
}
break;
// Custom Code Events
case ('CustomCodeEvent'):
{
switch (data.triggerCustomCodeEventName) {
case ('kickIncomingRaid'):
{
avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data.user, 'kick'));
const messageEl = document.createElement('div');
messageEl.innerHTML = `<b>${data.user}</b><br>is hosting with a party of<br><b>${data.viewers} viewers!</b>`;
contentEl.appendChild(messageEl);
// Set the platform icon
SetPlatformIcon(iconEl, 'kick');
}
break;
}
}
break;
// Don't print any event not excplicitly listed above
default:
return;
}
// Set the timestamp
const { DateTime } = luxon;
const now = DateTime.local();
const formatted = now.toFormat("cccc, LLLL d',' yyyy HH:mm:ss");
// Add ordinal suffix manually
function addOrdinal(n) {
if (n >= 11 && n <= 13) return 'th';
switch (n % 10) {
case 1: return 'st';
case 2: return 'nd';
case 3: return 'rd';
default: return 'th';
}
}
const day = now.day;
const ordinal = addOrdinal(day);
const fullFormatted = now.toFormat(`cccc, LLLL '${day}${ordinal}' yyyy HH:mm:ss`);
dateEl.textContent = fullFormatted;
// Send it to the print routine!
const receiptHTML = await GetRenderedHTML(instance);
console.log(receiptHTML);
client.doAction({ name: 'Printer Bot | Print Routine' }, {
receiptHTML: receiptHTML,
isTest: data.isTest
});
}
//////////////////////
// HELPER FUNCTIONS //
//////////////////////
async function GetAvatar(username, platform) {
// First, check if the username is hashed already
if (avatarMap.has(`${username}-${platform}`)) {
console.debug(`Avatar found for ${username} (${platform}). Retrieving from hash map.`)
return avatarMap.get(`${username}-${platform}`);
}
// If code reaches this point, the username hasn't been hashed, so retrieve avatar
switch (platform) {
case 'twitch':
{
console.debug(`No avatar found for ${username} (${platform}). Retrieving from Decapi.`)
let response = await fetch('https://decapi.me/twitch/avatar/' + username);
let data = await response.text();
avatarMap.set(`${username}-${platform}`, data);
return data;
}
case 'kick':
{
console.debug(`No avatar found for ${username} (${platform}). Retrieving from Kick.`)
try {
let response = await fetch('https://kick.com/api/v2/channels/' + username);
console.log('https://kick.com/api/v2/channels/' + username)
let data = await response.json();
let avatarURL = data.user.profile_pic;
if (!avatarURL)
avatarURL = 'https://kick.com/img/default-profile-pictures/default2.jpeg';
avatarMap.set(`${username}-${platform}`, avatarURL);
return avatarURL;
}
catch (error) {
console.debug(error);
return 'https://kick.com/img/default-profile-pictures/default2.jpeg';
}
}
}
}
async function GetRenderedHTML(fragment) {
if (!(fragment instanceof DocumentFragment)) {
throw new Error('Argument must be a DocumentFragment');
}
// Filter out comment nodes from fragment content
const nodes = Array.from(fragment.childNodes).filter(
node => node.nodeType !== Node.COMMENT_NODE
);
const bodyContent = nodes
.map(node => node.outerHTML || node.textContent)
.join('');
// Get inline <style> contents
const inlineStyles = Array.from(document.querySelectorAll('style'))
.map(style => style.textContent)
.join('\n');
// Get all external stylesheet URLs
const linkHrefs = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
.map(link => link.href);
// Fetch external CSS contents
const externalCSSContents = await Promise.all(
linkHrefs.map(async href => {
try {
const res = await fetch(href);
if (!res.ok) throw new Error(`Failed to load CSS from ${href}`);
return await res.text();
} catch {
console.warn(`Could not fetch CSS from ${href}`);
return '';
}
})
);
// Combine all CSS into one string
const combinedCSS = inlineStyles + '\n' + externalCSSContents.join('\n');
// Build the full standalone HTML string
const fullHTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Exported Page</title>
<style>${combinedCSS}</style>
</head>
<body>
${bodyContent}
</body>
</html>`.trim();
return fullHTML;
}
function ConvertWEBPToPNG(URL) {
return `https://images.weserv.nl/?url=${URL}&output=png`;
}
function FormatCurrency(amount, currency) {
const isISOCode = /^[A-Z]{3}$/.test(currency);
if (isISOCode) {
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency,
currencyDisplay: 'symbol',
}).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
// Handle some common symbols that go before the number
const symbolsBefore = ['$', '€', '£', '¥', '₹'];
if (symbolsBefore.includes(currency)) {
return `${currency}${amount.toFixed(2)}`;
}
// Otherwise default to appending after
return `${amount.toFixed(2)} ${currency}`;
}
function IsValidUrl(string) {
try {
new URL(string);
return true;
} catch {
return false;
}
}
function EscapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
function SetPlatformIcon(el, platform) {
// Set the platform icon
let baseURL = window.location.href;
baseURL = baseURL.replace(/index\.html$/i, '');
el.src = `${baseURL}/icons/platforms/${platform}.png`;
}
///////////////////////////////////
// STREAMER.BOT WEBSOCKET STATUS //
///////////////////////////////////
function SetConnectionState(isConnected) {
if (isConnected) {
document.getElementById('ip').disabled = true;
document.getElementById('port').disabled = true;
document.getElementById('connect-button').style.backgroundColor = '#e43b3b';
document.getElementById('connect-button').innerText = 'Disconnect';
localStorage.setItem('sbServerAddress', document.getElementById('ip').value);
localStorage.setItem('sbServerPort', document.getElementById('port').value);
}
else {
document.getElementById('ip').disabled = false;
document.getElementById('port').disabled = false;
document.getElementById('connect-button').style.backgroundColor = '#3be477';
document.getElementById('connect-button').innerText = 'Connect';
}
}
function Connect() {
if (document.getElementById('ip').disabled)
client.disconnect();
else
{
client.options.host = document.getElementById('ip').value;
client.options.port = document.getElementById('port').value;
client.connect();
}
}
-127
View File
@@ -1,127 +0,0 @@
body {
font-size: 16px;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
text-align: center;
margin: 0;
padding: 0;
}
#background {
color: white;
background-color: #282828;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
label {
font-weight: 600;
}
button {
font-size: 1em;
font-weight: 500;
background-color: #3be477;
color: white;
opacity: 0.8;
border-width: 0;
border-radius: 0.25em;
padding: 0.25em 0.5em;
width: 100%;
}
button:hover {
opacity: 1;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: inherit;
}
input {
border-radius: 0.5em;
width: calc(100% - 20px);
margin: 10px 0px;
padding: 10px 10px;
background-color: #ffffff05;
border-width: 0px;
color: white;
font-size: 1em;
}
input:disabled {
opacity: 0.5;
}
textarea:focus,
input:focus {
outline: none;
}
#connect-box {
font-size: 24px;
background-color: #181818;
text-align: left;
display: flex;
flex-direction: column;
gap: 1em;
padding: 2em;
border-radius: 0.5em;
}
.field {
display: flex;
flex-direction: column;
}
#header {}
#avatar {
width: 6em;
height: 6em;
border-radius: 50%;
object-fit: cover;
}
#title {
font-weight: 900;
font-size: 1.5em;
text-transform: uppercase;
}
#subtitle {
font-weight: 700;
font-size: 1.2em;
}
#attribute {
padding: 0.5em 0em;
}
#content {
/* border-top: 1px solid black;
border-bottom: 1px solid black;
border-left: none;
border-right: none;
margin: 1em 0em; */
padding: 0.5em 0em;
}
#icon {
height: 1em;
}
#date {
margin: 0.5em 0em;
font-size: 0.7em;
text-transform: uppercase;
}
#footer {}
.emote {
height: 1em;
}