Customization!

Add ability to control page title and color scheme.

Also, partially implements manual triggering of cronjobs.

Fixes #18
master
Dessa Simpson 2021-02-21 23:25:23 -07:00
parent 1c34b3f013
commit 6495e1c8ef
6 changed files with 294 additions and 40 deletions

View File

@ -1,4 +1,5 @@
var requestsDiv = document.getElementById("requests");
var cronJobs = ['processBans']
function getRequests(count,allRequests) {
var reqUrl;
@ -65,6 +66,22 @@ function applyUrlTransforms(url) {
}
}
function getColorObject() {
return {
bg: {
primary: document.getElementById('color-bg-primary').value,
table: document.getElementById('color-bg-table').value,
navbar: document.getElementById('color-bg-navbar').value,
error: document.getElementById('color-bg-error').value,
},
fg: {
primary: document.getElementById('color-fg-primary').value,
ahover: document.getElementById('color-fg-ahover').value,
title: document.getElementById('color-fg-title').value,
}
}
}
function addRequestErr(msg) {
document.getElementById('addRequestError').style.display = "inline-block";
document.getElementById('addRequestError').innerText = msg;
@ -85,33 +102,45 @@ function updateRequestErrReset() {
document.getElementById('updateRequestError').innerText = "";
}
function showMessage(msg) {
document.getElementById("messageModalText").innerText = msg;
function streamerSettingsErr(msg) {
document.getElementById('streamerSettingsError').style.display = "inline-block";
document.getElementById('streamerSettingsError').innerText = msg;
}
function streamerSettingsErrReset() {
document.getElementById('streamerSettingsError').style.display = "none";
document.getElementById('streamerSettingsError').innerText = "";
}
// Hides all modals in preparation to show a one or to close out of
// all modals and return to the page. Does NOT hide modalBackground.
function hideModals() {
document.getElementById("messageModal").style.display = "none";
document.getElementById("addRequestModal").style.display = "none";
document.getElementById("updateRequestModal").style.display = "none";
document.getElementById("deleteRequestModal").style.display = "none";
document.getElementById("streamerSettingsModal").style.display = "none";
}
function closeAllModals() {
hideModals();
document.getElementById("modalBackground").style.display = "none";
}
function showMessage(msg) {
hideModals();
document.getElementById("messageModalText").innerText = msg;
document.getElementById("modalBackground").style.display = "flex";
document.getElementById("messageModal").style.display = "block";
}
function closeMessageModal() {
document.getElementById("modalBackground").style.display = "none";
document.getElementById("messageModal").style.display = "none";
}
function openAddRequestModal() {
hideModals();
document.getElementById("modalBackground").style.display = "flex";
document.getElementById("updateRequestModal").style.display = "none";
document.getElementById("messageModal").style.display = "none";
document.getElementById("addRequestModal").style.display = "block";
}
function closeAddRequestModal() {
document.getElementById("modalBackground").style.display = "none";
document.getElementById("addRequestModal").style.display = "none";
}
function openUpdateRequestModal(tr) {
hideModals();
var url = tr.getElementsByClassName('request-link')[0].firstChild.href;
var score = tr.getElementsByClassName('request-score')[0].innerText;
var state = tr.getElementsByClassName('request-state')[0].innerText;
@ -120,18 +149,12 @@ function openUpdateRequestModal(tr) {
document.getElementById("updateRequestModalCurrentScore").innerText = score;
document.querySelector(`#updateRequestStateSelect [value="${state}"]`).selected = true;
document.getElementById("scoreModifierInput").value = 0;
document.getElementById("messageModal").style.display = "none";
document.getElementById("addRequestModal").style.display = "none";
document.getElementById("modalBackground").style.display = "flex";
document.getElementById("updateRequestModal").style.display = "block";
}
function closeUpdateRequestModal() {
document.getElementById("modalBackground").style.display = "none";
document.getElementById("updateRequestModal").style.display = "none";
}
function openDeleteRequestModal(url) {
hideModals();
document.getElementById("updateRequestUrl").href = url;
document.getElementById("updateRequestUrl").innerText = url;
document.getElementById("messageModal").style.display = "none";
@ -141,17 +164,21 @@ function openDeleteRequestModal(url) {
document.getElementById("deleteRequestModal").style.display = "block";
}
// Returns to update request modal
function closeDeleteRequestModal() {
hideModals();
document.getElementById("deleteRequestModal").style.display = "none";
document.getElementById("updateRequestModal").style.display = "block";
}
function closeAllModals() {
document.getElementById("messageModal").style.display = "none";
document.getElementById("addRequestModal").style.display = "none";
document.getElementById("updateRequestModal").style.display = "none";
document.getElementById("deleteRequestModal").style.display = "none";
document.getElementById("modalBackground").style.display = "none";
function openStreamerSettingsModal() {
hideModals();
document.getElementById("modalBackground").style.display = "flex";
document.getElementById("streamerSettingsModal").style.display = "block";
}
function cronRequest(job) {
if (!cronJobs.includes(job)) throw new Error("Request for invalid job");
}
const validUrlRegexes = [
@ -262,7 +289,33 @@ function deleteRequest(url) {
});
}
function updatePageTitle(pageTitle) {
streamerSettingsErrReset();
fetch("/api/updatePageTitle", { method: 'POST', body: new URLSearchParams({
pageTitle: pageTitle
})})
.then(response => {
if (response.ok) {
location.reload();
} else {
response.text().then(streamerSettingsErr);
}
});
}
function updateColors(colors) {
streamerSettingsErrReset();
fetch("/api/updateColors", { method: 'POST', headers: {
'Content-Type': 'application/json'
}, body: JSON.stringify(colors)})
.then(response => {
if (response.ok) {
location.reload();
} else {
response.text().then(streamerSettingsErr);
}
});
}
updateTable();

View File

@ -12,6 +12,12 @@ button, input, select {
font-size: 100%;
}
input[type="color"] {
border: none;
padding: 0px;
vertical-align: middle;
}
a {
color: #ddd;
}

View File

@ -30,6 +30,7 @@ const app = express();
app.use(version.checkVersionMiddleware);
app.use(express.static('public'));
app.use(express.urlencoded({extended: false}));
app.use(express.json());
app.use(session({
secret: config.sessionSecret,
saveUninitialized: false,
@ -189,6 +190,101 @@ app.post("/api/updateRequestScoreModifier", async (request, response) => {
.catch((e: any) => errorHandler(request,response,e));
});
app.post("/api/updatePageTitle", async (request, response) => {
if (request.session) await validateApiToken(request.session);
if (!request.session || !request.session.user) {
response.status(401);
response.send("Session expired; please log in again");
return;
}
var streamerid = await db.query(queries.getStreamerId).then((result: pg.QueryResult) => result.rows[0]['userid']);
if (request.session.user.id != streamerid) {
response.status(401);
response.send("You are not the streamer");
return;
}
if (!request.body.pageTitle) {
response.status(400);
response.send("Missing pageTitle");
return;
}
var pageTitle = request.body.pageTitle as string;
response.type('text/plain');
pageTitle = pageTitle.replace(/[&<>"']/g, function(m) {
switch (m) {
case '&':
return '&amp;';
case '<':
return '&lt;';
case '>':
return '&gt;';
case '"':
return '&quot;';
case "'":
return '&apos;';
default:
return '';
}
});
await db.query(Object.assign(queries.updatePageTitle,{ values: [pageTitle] }))
.catch((e: any) => errorHandler(request,response,e));
response.status(200);
response.send('Successfully updated page title');
});
app.post("/api/updateColors", async (request, response) => {
if (request.session) await validateApiToken(request.session);
if (!request.session || !request.session.user) {
response.status(401);
response.send("Session expired; please log in again");
return;
}
var streamerid = await db.query(queries.getStreamerId).then((result: pg.QueryResult) => result.rows[0]['userid']);
if (request.session.user.id != streamerid) {
response.status(401);
response.send("You are not the streamer");
return;
}
if (!request.body.bg) {
response.status(400);
response.send("Missing bg");
return;
}
if (!request.body.fg) {
response.status(400);
response.send("Missing fg");
return;
}
type Colors = { [key: string]: { [key: string]: string} }
var colors: Colors = { bg: {}, fg: {} };
console.log(JSON.stringify(request.body,null,2));
for (var color of ['primary','table','navbar','error']) {
var setcolor = request.body.bg[color];
if (/^#[0-9a-fA-F]{6}$/.test(setcolor)) {
colors.bg[color] = setcolor
} else {
response.status(400);
response.send(`Color 'bg.${color}' missing or invalid`)
return;
}
}
for (var color of ['primary','ahover','title']) {
var setcolor = request.body.fg[color];
if (/^#[0-9a-fA-F]{6}$/.test(setcolor)) {
colors.fg[color] = setcolor
} else {
response.status(400);
response.send(`Color 'fg.${color}' missing or invalid`)
return;
}
}
response.type('text/plain');
await db.query(Object.assign(queries.updateColors,{ values: [JSON.stringify(colors)] }))
.catch((e: any) => errorHandler(request,response,e));
response.status(200);
response.send('Successfully updated colors');
});
app.post("/api/deleteRequest", async (request, response) => {
if (request.session) await validateApiToken(request.session);
if (!request.session || !request.session.user) {
@ -266,6 +362,37 @@ app.post("/api/deleteVote", async (request,response) => {
.catch((e: any) => errorHandler(request,response,e));
});
app.get("/api/cronRequest", async (request, response) => {
if (request.session) await validateApiToken(request.session);
if (!request.session || !request.session.user) {
response.status(401);
response.send("Session expired; please log in again");
return;
}
var streamerid = await db.query(queries.getStreamerId).then((result: pg.QueryResult) => result.rows[0]['userid']);
if (request.session.user.id != streamerid) {
response.status(401);
response.send("You are not the streamer");
return;
}
if (!request.query.job) {
response.status(400);
response.send("Missing job");
return;
}
var job = request.body.job as string;
try {
cron.validateJob(job)
} catch (e) {
response.status(400);
response.send("Invalid job")
return;
}
response.type('text/plain');
cron.request(job).catch((e: any) => errorHandler(request,response,e));
});
// Twitch callback
app.get("/callback", async (request, response) => {
if (request.query.error) {
@ -302,7 +429,7 @@ app.get("/callback", async (request, response) => {
app.get("/", async (request, response) => {
if (request.session) await validateApiToken(request.session);
var streamerInfo = await db.query(queries.getStreamerInfo).then((result: pg.QueryResult) => result.rows[0]);
var config = await db.query(queries.getConfig).then((result: pg.QueryResult) => result.rows[0]);
var streamerConfig = await db.query(queries.getConfig).then((result: pg.QueryResult) => result.rows[0]);
if (typeof streamerInfo == 'undefined') {
response.redirect(307, `https://id.twitch.tv/oauth2/authorize?client_id=${config.twitchClientId}&redirect_uri=${config.urlPrefix}/callback&response_type=code&scope=channel:read:subscriptions moderation:read`);
return;
@ -312,7 +439,7 @@ app.get("/", async (request, response) => {
loggedIn: false,
clientId: config.twitchClientId,
urlPrefix: config.urlPrefix,
pageTitle: config.title,
pageTitle: streamerConfig.title,
streamerName: streamerInfo['displayname'],
streamerProfilePicture: streamerInfo['imageurl']
});
@ -324,9 +451,10 @@ app.get("/", async (request, response) => {
userProfilePicture: request.session.user.profile_image_url,
validStates: validStates,
isStreamer: streamerInfo['userid'] == request.session.user.id,
pageTitle: config.title,
pageTitle: streamerConfig.title,
streamerName: streamerInfo['displayname'],
streamerProfilePicture: streamerInfo['imageurl']
streamerProfilePicture: streamerInfo['imageurl'],
colors: streamerConfig.colors
});
}
});
@ -334,7 +462,6 @@ app.get("/", async (request, response) => {
app.get("/colors.css", async (_request, response) => {
var streamerInfo = await db.query(queries.getStreamerInfo).then((result: pg.QueryResult) => result.rows[0]);
var colors = await db.query(queries.getConfig).then((result: pg.QueryResult) => result.rows[0]['colors']);
console.log(colors);
if (typeof streamerInfo == 'undefined') return;
response.contentType("text/css");
response.render('colors.eta', colors);

View File

@ -9,6 +9,24 @@ interface CronJob {
(streamer: twitch.StreamerUserIdTokenPair): Promise<void>
}
function validateJob(job: string) {
if (!Object.keys(cronjobs).includes(job)) throw new Error("Invalid cronjob " + job);
}
async function runJob(job: string) {
validateJob(job);
var streamer = await db.query(queries.getStreamerIdToken).then(
(result: pg.QueryResult) => result.rows[0]);
return await (cronjobs as { [key: string]: CronJob })[job](streamer);
}
// Run a specific job on request of streamer
async function request(job: string) {
validateJob(job);
// TODO: Rate limiting
await runJob(job);
}
async function run() {
// If instance is not yet set up, end processing at this point
var streamer = await db.query(queries.getStreamerIdToken).then(
@ -42,11 +60,11 @@ async function run() {
log(LogLevel.ERROR,`cron: Job ${job} exception message: ${e}`)
} finally {
log(LogLevel.DEBUG,`cron: Job ${job} hit finally; releasing dbconn`);
await dbconn.release();
dbconn.release();
}
log(LogLevel.INFO,"cron: Finished job " + job);
}
log(LogLevel.INFO,"End cron run")
}
export = {run}
export = {run,validateJob,request}

View File

@ -37,6 +37,16 @@ export const updateStreamer = {
ON CONFLICT (userid) DO UPDATE SET tokenPair = $2"
}
export const updatePageTitle = {
name: "updatePageTitle",
text: "UPDATE config SET title = $1"
}
export const updateColors = {
name: "updateColors",
text: "UPDATE config SET colors = $1"
}
// Request-related queries
export const getRequests = {
name: "getRequests",

View File

@ -3,7 +3,7 @@
<head>
<link rel=stylesheet href=/style.css />
<link rel=stylesheet href=/colors.css />
<title><%= it.pageTitle.replace('{username}',it.streamerName) %></title>
<title><%~ it.pageTitle.replace('{username}',it.streamerName) %></title>
<script>
window.loggedIn = <%= it.loggedIn %>;
window.validStates = <%~ it.validStates %>;
@ -18,6 +18,9 @@
<div id="nav-requests"><a href="/">Requests</a></div>
<%- if (it.loggedIn) { -%>
<div id="nav-addrequest"><a href="#" onclick="openAddRequestModal()">Add Request</a></div>
<%- if (it.isStreamer) { -%>
<div id="nav-streamersettings"><a href="#" onclick="openStreamerSettingsModal()">Streamer Settings</a></div>
<%- } %>
<div id="nav-userpic"><img src="<%= it.userProfilePicture %>" /></div>
<div id="nav-username"><%= it.userName %></div>
<div id="nav-logout"><a href="/logout">Logout</a></div>
@ -39,11 +42,11 @@
</div>
<div id="modalBackground">
<div class="modal" id="messageModal">
<div class="modalClose"><a href="#" onclick="closeMessageModal()">&times;</a></div>
<div class="modalClose"><a href="#" onclick="closeAllModals()">&times;</a></div>
<span id="messageModalText"></span>
</div>
<div class="modal" id="addRequestModal">
<div class="modalClose"><a href="#" onclick="closeAddRequestModal()">&times;</a></div>
<div class="modalClose"><a href="#" onclick="closeAllModals()">&times;</a></div>
<h1>Add Request</h1>
<div class="error" id="addRequestError"></div>
<span id="addRequestInputContainer">
@ -53,7 +56,7 @@
Currently, only Youtube links are accepted.
</div>
<div class="modal" id="updateRequestModal">
<div class="modalClose"><a href="#" onclick="closeAddRequestModal()">&times;</a></div>
<div class="modalClose"><a href="#" onclick="closeAllModals()">&times;</a></div>
<h2>Update Request</h2>
<div class="error" id="updateRequestError"></div>
<br>
@ -84,7 +87,7 @@
</div>
<br>
<div>
<a id="updateMetadataLink" href="#" onclick="updateRequestMetadata(
<a href="#" onclick="updateRequestMetadata(
document.getElementById('updateRequestUrl').innerText
)">Update Request Metadata</a>
<br>
@ -112,6 +115,43 @@
<button onclick="closeDeleteRequestModal()">No</button>
<button onclick="deleteRequest(document.getElementById('updateRequestUrl').innerText)">Yes</button>
</div>
<div class="modal" id="streamerSettingsModal">
<div class="modalClose"><a href="#" onclick="closeAllModals()">&times;</a></div>
<h2>Streamer Settings</h2>
<div class="error" id="streamerSettingsError"></div>
<br>
<div>
<h3 style='margin-bottom: 0.5em'>Customization</h3>
<p>
Page Title:
<input type="text" id="pageTitle" style="width: 15em" value="<%~ it.pageTitle %>"></input>
<button onclick="updatePageTitle(document.getElementById('pageTitle').value)">Submit</button>
<button onclick="updatePageTitle('{username}\'s Learn Request Queue')">Reset</button>
</p>
<p>
<h3>Colors:</h3>
<p>Click a color to change it</p>
<b>Background:</b><br>
Primary: <input type="color" id="color-bg-primary" value="<%= it.colors.bg.primary %>"></input><br>
Table: <input type="color" id="color-bg-table" value="<%= it.colors.bg.table %>"></input><br>
Navbar: <input type="color" id="color-bg-navbar" value="<%= it.colors.bg.navbar %>"></input><br>
Error: <input type="color" id="color-bg-error" value="<%= it.colors.bg.error %>"></input><br>
<br>
<b>Foreground:</b><br>
Primary: <input type="color" id="color-fg-primary" value="<%= it.colors.fg.primary %>"></input><br>
Link Hover: <input type="color" id="color-fg-ahover" value="<%= it.colors.fg.ahover %>"></input><br>
Title: <input type="color" id="color-fg-title" value="<%= it.colors.fg.title %>"></input><br>
<br>
<button onclick="updateColors(getColorObject())">Submit</button>
<button onclick="updateColors({bg:{error: '#ff0000',table: '#282828',navbar: '#666666',primary: '#444444'},fg: { title: '#eeeeee', ahover: '#ffffff', primary: '#dddddd'}})">Reset</button>
</p>
</div>
<hr>
<div>
<h3 style='margin-bottom: 0.5em'>Batch Jobs</h3>
<a href="#" onclick="cronRequest('processBans')">Force Refresh Banned Users (NYI)</a>
</div>
</div>
</div>
</body>
</html>