diff --git a/.gitignore b/.gitignore
index fdb6826..e3ec01d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,7 +9,6 @@
# Ignore all environment files.
/.env*
-/public/comment-widget.js
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
diff --git a/public/comment-widget.js b/public/comment-widget.js
new file mode 100644
index 0000000..ae7d5eb
--- /dev/null
+++ b/public/comment-widget.js
@@ -0,0 +1,531 @@
+/*
+ (PLEASE DO NOT DELETE THIS HEADER OR CREDIT!)
+
+ User customizable settings below!
+ Please refer to my guide over on https://virtualobserver.moe/ayano/comment-widget if you're confused on how to use this.
+ The IDs at the top are a requirement but everything else is optional!
+ Do not delete any settings even if you aren't using them! It could break the program.
+
+ After filling out your options, just paste this anywhere you want a comment section
+ (But change the script src URL to wherever you have this widget stored on your site!)
+
+
+
+
+ Have fun! Bug reports are encouraged if you happen to run into any issues.
+ - Ayano (https://virtualobserver.moe/)
+*/
+
+// The values in this section are REQUIRED for the widget to work! Keep them in quotes!
+const s_stylePath = '/comment-widget-pink.css';
+const s_formId = '1FAIpQLSeoXQa-1tl1fy5T-gEByjFwzMKcdMIr2WWxoxzfDDbcFbhFsg';
+const s_nameId = '1472166240';
+const s_websiteId = '1345273360';
+const s_textId = '1075384199';
+const s_pageId = '975383655';
+const s_replyId = '1265170837';
+const s_sheetId = '1Y08cI8TPKlPznTVK1Vo_DfwuVW9m8jboZswDz76boL4';
+
+// The values below are necessary for accurate timestamps, I've filled it in with EST as an example
+const s_timezone = -5; // Your personal timezone (Example: UTC-5:00 is -5 here, UTC+10:30 would be 10.5)
+const s_daylightSavings = true; // If your personal timezone uses DST, set this to true
+// For the dates DST start and end where you live: [Month, Weekday, which number of that weekday, hour (24 hour time)]
+const s_dstStart = ['March', 'Sunday', 2, 2]; // Example shown is the second Sunday of March at 2:00 am
+const s_dstEnd = ['November', 'Sunday', 1, 2]; // Example shown is the first Sunday of November at 2:00 am
+
+// Misc - Other random settings
+const s_commentsPerPage = 5; // The max amount of comments that can be displayed on one page, any number >= 1 (Replies not counted)
+const s_maxLength = 800; // The max character length of a comment
+const s_maxLengthName = 16; // The max character length of a name
+const s_commentsOpen = true; // Change to false if you'd like to close your comment section site-wide (Turn it off on Google Forms too!)
+const s_collapsedReplies = true; // True for collapsed replies with a button, false for replies to display automatically
+const s_longTimestamp = false; // True for a date + time, false for just the date
+let s_includeUrlParameters = false; // Makes new comment sections on pages with URL parameters when set to true (If you don't know what this does, leave it disabled)
+const s_fixRarebitIndexPage = false; // If using Rarebit, change to true to make the index page and page 1 of your webcomic have the same comment section
+
+// Word filter - Censor profanity, etc
+const s_wordFilterOn = false; // True for on, false for off
+const s_filterReplacement = '****'; // Change what filtered words are censored with (**** is the default)
+const s_filteredWords = [ // Add words to filter by putting them in quotes and separating with commas (ie. 'heck', 'dang')
+ 'heck', 'dang'
+]
+
+// Text - Change what messages/text appear on the form and in the comments section (Mostly self explanatory)
+const s_widgetTitle = 'Leave a comment!';
+const s_nameFieldLabel = 'Name';
+const s_websiteFieldLabel = 'Website (Optional)';
+const s_textFieldLabel = '';
+const s_submitButtonLabel = 'Submit';
+const s_loadingText = 'Loading comments...';
+const s_noCommentsText = 'No comments yet!';
+const s_closedCommentsText = 'Comments are closed temporarily!';
+const s_websiteText = 'Website'; // The links to websites left by users on their comments
+const s_replyButtonText = 'Reply'; // The button for replying to someone
+const s_replyingText = 'Replying to'; // The text that displays while the user is typing a reply
+const s_expandRepliesText = 'Show Replies';
+const s_leftButtonText = '<<';
+const s_rightButtonText = '>>';
+
+/*
+ DO NOT edit below this point unless you are confident you know what you're doing!
+ Everything else is automatic, you don't have to change anything else. ^^
+ However, feel free to edit this code as much as you like! Just please don't remove my credit if possible <3
+*/
+
+// Fix the URL parameters setting for Rarebit just in case
+if (s_fixRarebitIndexPage) {s_includeUrlParameters = true}
+
+// Apply CSS
+const c_cssLink = document.createElement('link');
+c_cssLink.type = 'text/css';
+c_cssLink.rel = 'stylesheet';
+c_cssLink.href = s_stylePath;
+document.getElementsByTagName('head')[0].appendChild(c_cssLink);
+
+// HTML Form
+const v_mainHtml = `
+
+
+
+
${s_loadingText}
+`;
+const v_formHtml = `
+
${s_widgetTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+// Insert main HTML to page
+document.getElementById('c_widget').innerHTML = v_mainHtml;
+const c_form = document.getElementById('c_form');
+if (s_commentsOpen) {c_form.innerHTML = v_formHtml}
+else {c_form.innerHTML = s_closedCommentsText}
+
+// Initialize misc things
+const c_container = document.getElementById('c_container');
+let v_pageNum = 1;
+let v_amountOfPages = 1;
+let v_commentMax = 1;
+let v_commentMin = 1;
+
+// Set up the word filter if applicable
+let v_filteredWords;
+if (s_wordFilterOn) {
+ v_filteredWords = s_filteredWords.join('|');
+ v_filteredWords = new RegExp(String.raw `\b(${v_filteredWords})\b`, 'ig');
+}
+
+// The fake button is just a dummy placeholder for when comments are closed
+let c_submitButton;
+if (s_commentsOpen) {c_submitButton = document.getElementById('c_submitButton')}
+else {c_submitButton = document.createElement('button')}
+
+// Add invisible page input to document
+let v_pagePath = window.location.pathname;
+if (s_includeUrlParameters) {v_pagePath += window.location.search}
+if (s_fixRarebitIndexPage && v_pagePath == '/') {v_pagePath = '/?pg=1'}
+const c_pageInput = document.createElement('input');
+c_pageInput.value = v_pagePath; c_pageInput.type = 'text'; c_pageInput.style.display = 'none';
+c_pageInput.id = 'entry.' + s_pageId; c_pageInput.name = c_pageInput.id;
+c_form.appendChild(c_pageInput);
+
+// Add the "Replying to..." text to document
+let c_replyingText = document.createElement('span');
+c_replyingText.style.display = 'none'; c_replyingText.id = 'c_replyingText';
+c_form.appendChild(c_replyingText);
+c_replyingText = document.getElementById('c_replyingText');
+
+// Add the invisible reply input to document
+let c_replyInput = document.createElement('input');
+c_replyInput.type = 'text'; c_replyInput.style.display = 'none';
+c_replyInput.id = 'entry.' + s_replyId; c_replyInput.name = c_replyInput.id;
+c_form.appendChild(c_replyInput);
+c_replyInput = document.getElementById('entry.' + s_replyId);
+
+// Add the invisible iFrame to the document for catching the default Google Forms submisson page
+let v_submitted = false;
+let c_hiddenIframe = document.createElement('iframe');
+c_hiddenIframe.id = 'c_hiddenIframe'; c_hiddenIframe.name = 'c_hiddenIframe'; c_hiddenIframe.style.display = 'none'; c_hiddenIframe.setAttribute('onload', 'if(v_submitted){fixFrame()}');
+c_form.appendChild(c_hiddenIframe);
+c_hiddenIframe = document.getElementById('c_hiddenIframe');
+
+// Fix the invisible iFrame so it doesn't keep trying to load stuff
+function fixFrame() {
+ v_submitted = false;
+ c_hiddenIframe.srcdoc = '';
+ getComments(); // Reload comments after submission
+}
+
+// Processes comment data with the Google Sheet ID
+function getComments() {
+ // Disable the submit button while comments are reloaded
+ c_submitButton.disabled;
+
+ // Reset reply stuff to default
+ c_replyingText.style.display = 'none';
+ c_replyInput.value = '';
+
+ // Clear input fields too
+ if (s_commentsOpen) {
+ document.getElementById(`entry.${s_nameId}`).value = '';
+ document.getElementById(`entry.${s_websiteId}`).value = '';
+ document.getElementById(`entry.${s_textId}`).value = '';
+ }
+
+ // Get the data
+ const url = `https://docs.google.com/spreadsheets/d/${s_sheetId}/gviz/tq?`;
+ const retrievedSheet = getSheet(url);
+
+ // Do stuff with the data here
+ retrievedSheet.then(result => {
+ // The data comes with extra stuff at the beginning, get rid of it
+ const json = JSON.parse(result.split('\n')[1].replace(/google.visualization.Query.setResponse\(|\);/g, ''));
+
+ // Need index of page column for checking if comments are for the right page
+ const isPage = (col) => col.label == 'Page';
+ let pageIdx = json.table.cols.findIndex(isPage);
+
+ // Turn that data into usable comment data
+ // All of the messy val checks are because Google Sheets can be weird sometimes with comment deletion
+ let comments = [];
+ if (json.table.parsedNumHeaders > 0) { // Check if any comments exist in the sheet at all before continuing
+ for (r = 0; r < json.table.rows.length; r++) {
+ // Check for null rows
+ let val1;
+ if (!json.table.rows[r].c[pageIdx]) {val1 = ''}
+ else {val1 = json.table.rows[r].c[pageIdx].v}
+
+ // Check if the page name matches before adding to comment array
+ if (val1 == v_pagePath) {
+ let comment = {}
+ for (c = 0; c < json.table.cols.length; c++) {
+ // Check for null values
+ let val2;
+ if (!json.table.rows[r].c[c]) {val2 = ''}
+ else {val2 = json.table.rows[r].c[c].v}
+
+ // Finally set the value properly
+ comment[json.table.cols[c].label] = val2;
+ }
+ comment.Timestamp2 = json.table.rows[r].c[0].f;
+ comments.push(comment);
+ }
+ }
+ }
+
+ // Check for empty comments before displaying to page
+ if (comments.length == 0 || Object.keys(comments[0]).length < 2) { // Once again, Google Sheets can be weird
+ c_container.innerHTML = s_noCommentsText;
+ } else {displayComments(comments)}
+
+ c_submitButton.disabled = false // Now that everything is done, re-enable the submit button
+ })
+}
+
+// Fetches the Google Sheet resource from the provided URL
+function getSheet(url) {
+ return new Promise(function (resolve, reject) {
+ fetch(url).then(response => {
+ if (!response.ok) {reject('Could not find Google Sheet with that URL')} // Checking for a 404
+ else {
+ response.text().then(data => {
+ if (!data) {reject('Invalid data pulled from sheet')}
+ resolve(data);
+ })
+ }
+ })
+ })
+}
+
+// Displays comments on page
+let a_commentDivs = []; // For use in other functions
+function displayComments(comments) {
+ // Clear for re-display
+ a_commentDivs = [];
+ c_container.innerHTML = '';
+
+ // Get all reply comments by taking them out of the comment array
+ let replies = [];
+ for (i = 0; i < comments.length; i++) {
+ if (comments[i].Reply) {
+ replies.push(comments[i]);
+ comments.splice(i, 1);
+ i--;
+ }
+ }
+
+ // Values for pagination
+ v_amountOfPages = Math.ceil(comments.length / s_commentsPerPage);
+ v_commentMax = s_commentsPerPage * v_pageNum;
+ v_commentMin = v_commentMax - s_commentsPerPage;
+
+ // Main comments (not replies)
+ comments.reverse(); // Newest comments go to top
+ for (i = 0; i < comments.length; i++) {
+ let comment = createComment(comments[i]);
+
+ // Reply button
+ let button = document.createElement('button');
+ button.innerHTML = s_replyButtonText;
+ button.value = comment.id;
+ button.setAttribute('onclick', `openReply(this.value)`);
+ button.className = 'c-replyButton';
+ comment.appendChild(button);
+
+ // Choose whether to display or not based on page number
+ comment.style.display = 'none';
+ if (i >= v_commentMin && i < v_commentMax) {comment.style.display = 'block'}
+
+ comment.className = 'c-comment';
+ c_container.appendChild(comment);
+ a_commentDivs.push(document.getElementById(comment.id)); // Add to array for use later
+ }
+
+ // Replies
+ for (i = 0; i < replies.length; i++) {
+ let reply = createComment(replies[i]);
+ const parentId = replies[i].Reply;
+ const parentDiv = document.getElementById(parentId);
+
+ // Check if a container doesn't already exist for this comment, if not, make one
+ let container;
+ if (!document.getElementById(parentId + '-replies')) {
+ container = document.createElement('div');
+ container.id = parentId + '-replies';
+ if (s_collapsedReplies) {container.style.display = 'none'} // Default to hidden if collapsed
+ container.className = 'c-replyContainer';
+ parentDiv.appendChild(container);
+ } else {container = document.getElementById(parentId + '-replies')}
+ reply.className = 'c-reply';
+ container.appendChild(reply);
+ }
+
+ // Handle adding the buttons to show or hide replies if collapsed replies are enabled
+ if (s_collapsedReplies) {
+ const containers = document.getElementsByClassName('c-replyContainer');
+ for (i = 0; i < containers.length; i++) {
+ const num = containers[i].childNodes.length;
+ const parentDiv = containers[i].parentElement;
+
+ // The button to expand replies
+ const button = document.createElement('button');
+ button.innerHTML = s_expandRepliesText + ` (${num})`;
+ button.setAttribute('onclick', `expandReplies(this.parentElement.id)`);
+ button.className = 'c-expandButton';
+ parentDiv.insertBefore(button, parentDiv.lastChild);
+ }
+ }
+
+ // Handle pagination if there's more than one page
+ if (v_amountOfPages > 1) {
+ let pagination = document.createElement('div');
+
+ leftButton = document.createElement('button');
+ leftButton.innerHTML = s_leftButtonText; leftButton.id = 'c_leftButton'; leftButton.name = 'left';
+ leftButton.setAttribute('onclick', `changePage(this.name)`);
+ if (v_pageNum == 1) {leftButton.disabled = true} // Can't go before page 1
+ leftButton.className = 'c-paginationButton';
+ pagination.appendChild(leftButton);
+
+ rightButton = document.createElement('button');
+ rightButton.innerHTML = s_rightButtonText; rightButton.id = 'c_rightButton'; rightButton.name = 'right';
+ rightButton.setAttribute('onclick', `changePage(this.name)`);
+ if (v_pageNum == v_amountOfPages) {rightButton.disabled = true} // Can't go after the last page
+ rightButton.className = 'c-paginationButton';
+ pagination.appendChild(rightButton);
+
+ pagination.id = 'c_pagination';
+ c_container.appendChild(pagination);
+ }
+}
+
+// Create basic HTML comment, reply or not
+function createComment(data) {
+ let comment = document.createElement('div');
+
+ // Get the right timestamps
+ let timestamps = convertTimestamp(data.Timestamp);
+ let timestamp;
+ if (s_longTimestamp) {timestamp = timestamps[0]}
+ else {timestamp = timestamps[1]}
+
+ // Set the ID (uses Name + Full Timestamp format)
+ const id = data.Name + '|--|' + data.Timestamp2;
+ comment.id = id;
+
+ // Name of user
+ let name = document.createElement('h3');
+ let filteredName = data.Name;
+ if (s_wordFilterOn) {filteredName = filteredName.replace(v_filteredWords, s_filterReplacement)}
+ name.innerText = filteredName;
+ name.className = 'c-name';
+ comment.appendChild(name);
+
+ // Timestamp
+ let time = document.createElement('span');
+ time.innerText = timestamp;
+ time.className = 'c-timestamp';
+ comment.appendChild(time);
+
+ // Website URL, if one was provided
+ if (data.Website) {
+ let site = document.createElement('a');
+ site.innerText = s_websiteText;
+ site.href = data.Website;
+ site.className = 'c-site';
+ comment.appendChild(site);
+ }
+
+ // Text content
+ let text = document.createElement('p');
+ let filteredText = data.Text;
+ if (s_wordFilterOn) {filteredText = filteredText.replace(v_filteredWords, s_filterReplacement)}
+ text.innerText = filteredText;
+ text.className = 'c-text';
+ comment.appendChild(text);
+
+ return comment;
+}
+
+// Makes the Google Sheet timestamp usable
+function convertTimestamp(timestamp) {
+ const vals = timestamp.split('(')[1].split(')')[0].split(',');
+ const date = new Date(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
+ const timezoneDiff = (s_timezone * 60 + date.getTimezoneOffset()) * -1;
+ let offsetDate = new Date(date.getTime() + timezoneDiff * 60 * 1000);
+ if (s_daylightSavings) {offsetDate = isDST(offsetDate)}
+ return [offsetDate.toLocaleString(), offsetDate.toLocaleDateString()];
+}
+// DST checker
+function isDST(date) {
+ const dstStart = [getMonthNum(s_dstStart[0]), getDayNum(s_dstStart[1]), s_dstStart[2], s_dstStart[3]];
+ const dstEnd = [getMonthNum(s_dstEnd[0]), getDayNum(s_dstEnd[1]), s_dstEnd[2], s_dstEnd[3]];
+
+ const year = date.getFullYear();
+ let startDate = new Date(year, dstStart[0], 1);
+ startDate = nthDayOfMonth(dstStart[1], dstStart[2], startDate, dstStart[3]).getTime();
+ let endDate = new Date(year, dstEnd[0], 1);
+ endDate = nthDayOfMonth(dstEnd[1], dstEnd[2], endDate, dstEnd[3]).getTime();
+ time = date.getTime();
+
+ if (time >= startDate && time < endDate) {date.setHours(date.getHours() - 1)}
+ return date;
+}
+// Thank you to https://stackoverflow.com/questions/32192982/get-a-given-weekday-in-a-given-month-with-javascript for the below function
+function nthDayOfMonth(day, n, date, hour) {
+ var count = 0;
+ var idate = new Date(date);
+ idate.setDate(1);
+ while ((count) < n) {
+ idate.setDate(idate.getDate() + 1);
+ if (idate.getDay() == day) {
+ count++;
+ }
+ }
+ idate.setHours(hour);
+ return idate;
+}
+// Convert weekday and month names into numbers
+function getDayNum(day) {
+ let num;
+ switch (day.toLowerCase()) {
+ case 'sunday': num = 0; break;
+ case 'monday': num = 1; break;
+ case 'tuesday': num = 2; break;
+ case 'wednesday': num = 3; break;
+ case 'thursday': num = 4; break;
+ case 'friday': num = 5; break;
+ case 'saturday': num = 6; break;
+ default: num = 0; break;
+ }
+ return num;
+}
+function getMonthNum(month) {
+ let num;
+ switch (month.toLowerCase()) {
+ case 'january': num = 0; break;
+ case 'february': num = 1; break;
+ case 'march': num = 2; break;
+ case 'april': num = 3; break;
+ case 'may': num = 4; break;
+ case 'june': num = 5; break;
+ case 'july': num = 6; break;
+ case 'august': num = 7; break;
+ case 'september': num = 8; break;
+ case 'october': num = 9; break;
+ case 'november': num = 10; break;
+ case 'december': num = 11; break;
+ }
+ return num;
+}
+
+// Handle making replies
+const link = document.createElement('a');
+link.href = '#c_inputDiv';
+function openReply(id) {
+ if (c_replyingText.style.display == 'none') {
+ c_replyingText.innerHTML = s_replyingText + ` ${id.split('|--|')[0]}...`;
+ c_replyInput.value = id;
+ c_replyingText.style.display = 'block';
+ } else {
+ c_replyingText.innerHTML = '';
+ c_replyInput.value = '';
+ c_replyingText.style.display = 'none';
+ }
+ link.click(); // Jump to the space to type
+}
+
+// Handle expanding replies (should only be accessible with collapsed replies enabled)
+function expandReplies(id) {
+ const targetDiv = document.getElementById(`${id}-replies`);
+ if (targetDiv.style.display == 'none') {targetDiv.style.display = 'block'}
+ else {targetDiv.style.display = 'none'}
+}
+
+function changePage(dir) {
+ const leftButton = document.getElementById('c_leftButton');
+ const rightButton = document.getElementById('c_rightButton');
+
+ // Find directional number
+ let num;
+ switch (dir) {
+ case 'left': num = -1; break;
+ case 'right': num = 1; break;
+ default: num = 0; break;
+ }
+ let targetPage = v_pageNum + num;
+
+ // Cancel if impossible direction for safety, should never happen though
+ if (targetPage > v_amountOfPages || targetPage < 1) {return}
+
+ // Enable/disable buttons if needed
+ leftButton.disabled = false; rightButton.disabled = false;
+ if (targetPage == 1) {leftButton.disabled = true} // Can't go before page 1
+ if (targetPage == v_amountOfPages) {rightButton.disabled = true} // Can't go past the last page
+
+ // Hide all comments and then display the correct ones
+ v_pageNum = targetPage;
+ v_commentMax = s_commentsPerPage * v_pageNum;
+ v_commentMin = v_commentMax - s_commentsPerPage;
+ for (i = 0; i < a_commentDivs.length; i++) {
+ a_commentDivs[i].style.display = 'none';
+ if (i >= v_commentMin && i < v_commentMax) {a_commentDivs[i].style.display = 'block'}
+ }
+}
+
+getComments(); // Run once on page load