My Airtable Comments Script
2025-08-06
This is really kind of dashed off but as a kind of alternative to the virtualobserver comment widget I made my own using Airtable since I wanted some more granular control over what I was doing. Airtable has a pretty generous free system; if you go over like 1000 comments you will maybe need something else but if you're a small fry this will work. If you have more comments than that idk maybe consider moving to Wordpress?
This also optionally has a rating system if you want people to be able to rate something! I don't know what you'd do with that data but who knows. (I have mine set up so that if i set rating = True in my post metadata it'll show the rating section and otherwise not.)
You'll want to change the token, base, table name, and allowed hosts to the appropriate values below but apart from that you can drop it in pretty easily.
// NOTE: STORING THE TOKEN IN PLAINTEXT ISN'T IDEAL but i don't have another way to do it on Neocities. If you're running this on a site where you can get it from an .env file or bash exports that's ideal though.
const token = "patmcurcArZTspXpO.25fe251479f560b27cfc5c309276bd0b29076da64f82a97842ed7af449d1452f"; // Airtable API token. scope this as narrowly as you can
const base = "app6vVxlXk2DW6iYs"; // ID of your Airtable base
const tableName = "Comments"; // Name of the table/sheet where comments are stored in your base
const allowedHosts = ["localhost", "127.0.0.1", "games.birdwrongs.sh"];
const pathname = window.location.pathname; // gets the path for the page
const host = window.location.hostname;
const box = document.querySelector('.commentbox'); // change this if you change the selector for your comment box
const urlbase = "https://api.airtable.com/v0/"+base+"/"+tableName+"?filterByFormula=%7BPath%7D%3D";
const bleeps = []; // bleep out words if they're present
const bans = []; // reject a comment if it has these words
// some element defs
const replyBanner = document.getElementById('in-reply-banner');
const replyField = document.getElementById('reply');
const flashMessage = document.getElementById('flash-message');
const commentForm = document.getElementById('comment-form');
let average = 0;
let count = 0;
const getComments = async (offset) => {
const off = null || offset;
if (!offset) {
count = 0;
average = 0;
}
if (allowedHosts.includes(host)) { // check that this is an allowed domain
const h = new Headers();
h.set('Authorization', 'Bearer '+token);
const data = await fetch(urlbase+JSON.stringify(pathname)+"&sort%5B0%5D%5Bfield%5D=Date&sort%5B0%5D%5Bdirection%5D=asc", {
method: 'GET',
headers: h
}); // gets the comments in a batch size of 100
const rec = await data.json();
if (rec.records.length > 0 && !offset) {
box.innerHTML += "<br />";
}
rec.records.forEach((item) => {
let ratingString = "";
if ('Rating' in item.fields) {
ratingString += " (";
for (let i = 0; i < item.fields['Rating']; i++) {
ratingString += '<i class="fa-solid fa-star"></i>';
}
ratingString += ")";
count++;
average += item.fields['Rating'];
}
const content = `
<div class="comment" id="${item.id}" data-id="${item.id}"${ 'Reply' in item.fields ? ` data-reply="${item.fields['Reply'][0]}"` : ''} data-datetime="${item.fields['Date']}">
<div class="comment-header">
<p class="comment-user">
${item.fields['Website'] ? "<a href='"+item.fields['Website']+"' target='_blank'>" : ""}
${item.fields['Name']}
${item.fields['Website'] ? "</a>" : ""}
${ratingString}
</p>
<p class="datetime"><a href="#${item.id}">${item.fields['Date']}</a></p>
</div>
<div class="comment-body">
${item.fields['Comment']}
</div>
<div class="comment-reply-button">
<a href="#" class="reply-link" data-reply-to="${item.id}"><i class="fa-solid fa-reply"></i> Reply</a>
</div>
<div class="comment-children"></div>
</div>`;
if ('Reply' in item.fields) {
document.querySelector(`[data-id="${item.fields['Reply'][0]}"] .comment-children`).innerHTML += content;
} else {
box.innerHTML += content;
}
});
if (rec.offset) {
getComments(rec.offset); // if there's more comments in this query, get the next batch
} else {
if (average > 0) { // if there's a rating, show the average
document.getElementById('avg-rating').innerHTML = ` (Avg. Rating: ${average / count})`;
}
}
}
}
const sanitize = (content) => {
let modifiedComment = content;
let banflag = false;
bans.forEach((ban) => {
if (content.match(ban)) {
banflag = true;
return;
}
});
if (banflag) {
return false;
}
bleeps.forEach((bleep) => {
modifiedComment = modifiedComment.replace(bleep, "".padStart(bleep.length, "*"));
});
return modifiedComment;
}
document.getElementById('comment-form').addEventListener('submit', (e) => {
e.preventDefault();
const sanitizedName = sanitize(document.getElementById('name').value);
const sanitizedComment = sanitize(document.getElementById('comment').value);
if (!sanitizedName || !sanitizedComment) {
return;
}
const h = new Headers();
h.set('Authorization', 'Bearer '+token);
h.set('Content-Type', 'application/json');
const data = fetch(urlbase+JSON.stringify(pathname)+"&sort%5B0%5D%5Bfield%5D=Date&sort%5B0%5D%5Bdirection%5D=asc", {
method: 'POST',
headers: h,
body: JSON.stringify({
"records": [
{
"fields": {
"Date": new Date().toISOString(),
"Name": document.getElementById('name').value,
"Website": document.getElementById('website').value,
"Path": pathname,
"Comment": document.getElementById('comment').value,
"Rating": document.getElementById('rating') ? document.getElementById('rating').value : null,
"Approved": true, // change this to false if you want to hand-moderate everything
"Reply": document.getElementById('reply').value !== "" ? [document.getElementById('reply').value] : []
}
}
]
})
}).then((val) => {
val.json().then((resp) => {
replyBanner.style.display = "";
replyBanner.innerHTML = "";
flashMessage.style.display = "block";
flashMessage.innerHTML = "Your comment was posted successfully!";
commentForm.reset();
box.innerHTML = "";
getComments();
window.location.href = '#'+resp.records[0].id;
});
});
});
document.addEventListener('click', (e) => {
if (e.target.closest('a.reply-link')) {
e.preventDefault();
const replyTo = e.target.closest('a.reply-link').getAttribute('data-reply-to');
replyField.value = replyTo;
replyBanner.innerHTML = `Replying to <a href="#${replyTo}">#${replyTo}</a> <a href="#" class="remove-reply"><i class="fa-solid fa-square-xmark"></i></a>`;
replyBanner.style.display = "block";
window.location.href = '#comment-form';
} else if (e.target.closest('a.remove-reply')) {
e.preventDefault();
replyBanner.style.display = "none";
replyBanner.innerHTML = "";
replyField.value = "";
}
});
document.querySelectorAll('.rating-group button').forEach((elt) => {
elt.addEventListener('click', (e) => {
let current = e.target.closest('button');
const val = current.value;
e.target.closest('.rating-group').querySelector('input[type="hidden"]').value = val;
current.classList.add('on');
while (current.previousElementSibling) {
current.previousElementSibling.classList.add('on');
current = current.previousElementSibling;
}
current = e.target;
while (current.nextElementSibling) {
current.nextElementSibling.classList.remove('on');
current = current.nextElementSibling;
}
});
});
if (document.querySelector('.commentbox')) {
getComments();
}
And then the HTML for the form and the div where you put the comments:
<div id="comment-flash"></div>
<div id="in-reply-banner"></div>
<form id="comment-form" method="POST">
<div class="form-row">
<label for="name">Name</label>
<input type="text" name="name" id="name" />
</div>
<div class="form-row">
<label for="website">Website</label>
<input type="text" name="website" id="website" />
</div>
<!-- you can omit this next row if desired and it'll be ok -->
<div class="form-row">
<label for="rating">Rating</label>
<span class="rating-group">
<button type="button" class="radio-button" value="1"><i class="fa-solid fa-star"></i><i class="fa-regular fa-star"></i><span class="sr-only">1</span></button>
<button type="button" class="radio-button" value="2"><i class="fa-solid fa-star"></i><i class="fa-regular fa-star"></i><span class="sr-only">2</span></button>
<button type="button" class="radio-button" value="3"><i class="fa-solid fa-star"></i><i class="fa-regular fa-star"></i><span class="sr-only">3</span></button>
<button type="button" class="radio-button" value="4"><i class="fa-solid fa-star"></i><i class="fa-regular fa-star"></i><span class="sr-only">4</span></button>
<button type="button" class="radio-button" value="5"><i class="fa-solid fa-star"></i><i class="fa-regular fa-star"></i><span class="sr-only">5</span></button>
<input type="hidden" name="rating" id="rating" value="" />
</span>
</div>
<div class="form-row">
<label for="comment">Comment</label>
<textarea name="comment" id="comment"></textarea>
</div>
<div class="form-row">
<input type="hidden" id="reply" name="reply" value="" />
<button type="submit">Send!</button>
</div>
</form>
<div class="commentbox"></div>