v0.1

Data TablesWebflow CMS
#219 - Like / Unlike Buttons For CMS Items
Toggle likes on Webflow CMS items and show a live like count.
Add upvote and downvote buttons with a live vote counter.
Watch the video for step-by-step implementation instructions
<!-- 💙 MEMBERSCRIPT #217 v0.1 💙 LIKE / VOTE COUNTER(MEMBERSTACK DATA TABLES) -->
<script>
document.addEventListener("DOMContentLoaded", () => {
// CONFIG: customize these to match your design
const CONFIG = {
tableName: "votes",
pageSize: 100,
loginAlertMessage: "Please login to vote.",
itemIdAttribute: "data-item-id",
itemSelector: "[data-item-id]",
countSelector: "[data-vote-count]",
upSelector: "[data-vote-up]",
downSelector: "[data-vote-down]",
activeUpClass: "is-upvoted",
activeDownClass: "is-downvoted",
upHoverColor: "#03035e",
downHoverColor: "#7f1d1d",
upActiveIcon: "#4353ff",
downActiveIcon: "#e11d48",
upCountColor: "#4353ff",
downCountColor: "#e11d48"
};
document.documentElement.style.setProperty(
"--ms217-vote-up-hover-fill",
CONFIG.upHoverColor
);
document.documentElement.style.setProperty(
"--ms217-vote-down-hover-fill",
CONFIG.downHoverColor
);
document.documentElement.style.setProperty(
"--ms217-vote-up-active-icon",
CONFIG.upActiveIcon
);
document.documentElement.style.setProperty(
"--ms217-vote-down-active-icon",
CONFIG.downActiveIcon
);
document.documentElement.style.setProperty(
"--ms217-vote-up-active-count",
CONFIG.upCountColor
);
document.documentElement.style.setProperty(
"--ms217-vote-down-active-count",
CONFIG.downCountColor
);
const getMS = async () => window.$memberstackDom || null;
const getCurrentMemberSafe = async (ms) => {
try {
const res = await ms.getCurrentMember();
return res && (res.data || res);
} catch (error) {
console.warn("memberscript217 current member error:", error);
return null;
}
};
const getItemIdFromCard = (card) => {
if (!card) return null;
const attr = CONFIG.itemIdAttribute || "data-item-id";
const value =
card.getAttribute(attr) ||
card.getAttribute("data-ms-record-id") ||
card.getAttribute("data-item-id");
return value || null;
};
const fetchAllVotesForItems = async (ms, itemIds) => {
const all = [];
let skip = 0;
let page = [];
do {
const result = await ms.queryDataRecords({
table: CONFIG.tableName,
query: {
where: { item: { in: itemIds } },
take: CONFIG.pageSize,
skip
}
});
page = (result.data && result.data.records) || [];
all.push(...page);
skip += page.length;
} while (page.length === CONFIG.pageSize);
return all;
};
const updateDisplay = (card, netCount, userType) => {
if (!card) return;
const countEl = card.querySelector(CONFIG.countSelector);
const upEl = card.querySelector(CONFIG.upSelector);
const downEl = card.querySelector(CONFIG.downSelector);
if (countEl) {
const value = typeof netCount === "number" ? netCount : 0;
countEl.textContent = String(value);
countEl.dataset.voteState =
userType === "upvote" ? "up" : userType === "downvote" ? "down" : "none";
}
if (upEl) {
upEl.classList.toggle(CONFIG.activeUpClass, userType === "upvote");
}
if (downEl) {
downEl.classList.toggle(CONFIG.activeDownClass, userType === "downvote");
}
};
const syncVotesForCards = async (cards) => {
const ms = await getMS();
if (!ms) return;
const list = Array.from(cards || []);
if (!list.length) return;
const itemIds = Array.from(
new Set(
list
.map((card) => getItemIdFromCard(card))
.filter(Boolean)
)
);
if (!itemIds.length) return;
try {
const member = await getCurrentMemberSafe(ms);
const records = await fetchAllVotesForItems(ms, itemIds);
const byItem = new Map();
records.forEach((record) => {
const rawItem = record && record.data && record.data.item;
const itemId = rawItem && (rawItem.id || rawItem);
if (!itemId) return;
if (!byItem.has(itemId)) {
byItem.set(itemId, { up: 0, down: 0, userRecord: null });
}
const entry = byItem.get(itemId);
const t = record.data && record.data.type;
if (t === "upvote") entry.up += 1;
else if (t === "downvote") entry.down += 1;
if (
member &&
!entry.userRecord &&
record.data &&
record.data.member === member.id
) {
entry.userRecord = record;
}
});
list.forEach((card) => {
const itemId = getItemIdFromCard(card);
if (!itemId) return;
const entry = byItem.get(itemId) || { up: 0, down: 0, userRecord: null };
const net = entry.up - entry.down;
let userType = null;
let userRecordId = null;
if (entry.userRecord) {
userRecordId = entry.userRecord.id || null;
userType = entry.userRecord.data && entry.userRecord.data.type;
}
if (userRecordId) {
card.dataset.msVoteRecordId = userRecordId;
card.dataset.msVoteType = userType || "";
} else {
delete card.dataset.msVoteRecordId;
delete card.dataset.msVoteType;
}
updateDisplay(card, net, userType);
});
} catch (error) {
console.warn("memberscript217 sync failed:", error);
}
};
const handleVoteAction = async (card, desiredType) => {
const ms = await getMS();
if (!ms || !card || !desiredType) return;
const member = await getCurrentMemberSafe(ms);
if (!member) {
alert(CONFIG.loginAlertMessage);
return;
}
const itemId = getItemIdFromCard(card);
if (!itemId) return;
const existingRecordId = card.dataset.msVoteRecordId || null;
const existingType = card.dataset.msVoteType || null;
const countEl = card.querySelector(CONFIG.countSelector);
const currentNet = countEl ? Number(countEl.textContent) || 0 : 0;
card.classList.add("vote_loading");
const computeNewNet = () => {
if (!existingRecordId) {
return desiredType === "upvote" ? currentNet + 1 : currentNet - 1;
}
if (existingType === desiredType) {
return desiredType === "upvote" ? currentNet - 1 : currentNet + 1;
}
return desiredType === "upvote" ? currentNet + 2 : currentNet - 2;
};
try {
const newNet = computeNewNet();
if (existingRecordId && existingType === desiredType) {
updateDisplay(card, newNet, null);
delete card.dataset.msVoteRecordId;
delete card.dataset.msVoteType;
await ms.deleteDataRecord({ recordId: existingRecordId });
} else if (existingRecordId && existingType && existingType !== desiredType) {
updateDisplay(card, newNet, desiredType);
card.dataset.msVoteType = desiredType;
const now = new Date().toISOString();
await ms.updateDataRecord({
recordId: existingRecordId,
data: {
type: desiredType,
updated_at: now
}
});
} else {
const now = new Date().toISOString();
const baseData = {
item: itemId,
member: member.id,
type: desiredType,
created_at: now,
created_at: now
};
updateDisplay(card, newNet, desiredType);
let result;
try {
result = await ms.createDataRecord({
table: CONFIG.tableName,
data: baseData
});
} catch (directIdError) {
result = await ms.createDataRecord({
table: CONFIG.tableName,
data: {
...baseData,
item: { id: itemId }
}
});
}
const newId = result && result.data && result.data.id;
if (newId) {
card.dataset.msVoteRecordId = newId;
card.dataset.msVoteType = desiredType;
}
}
} catch (error) {
console.error("memberscript217 action failed:", error);
syncVotesForCards([card]);
} finally {
card.classList.remove("vote_loading");
}
};
const voteItems = document.querySelectorAll(CONFIG.itemSelector);
if (voteItems.length) {
syncVotesForCards(voteItems);
voteItems.forEach((card) => {
const upEl = card.querySelector(CONFIG.upSelector);
const downEl = card.querySelector(CONFIG.downSelector);
if (upEl) {
upEl.addEventListener("click", (event) => {
event.preventDefault();
handleVoteAction(card, "upvote");
});
}
if (downEl) {
downEl.addEventListener("click", (event) => {
event.preventDefault();
handleVoteAction(card, "downvote");
});
}
});
}
});
</script>
<style>
.vote_icon[data-vote-up]:hover {
fill: var(--ms217-vote-up-hover-fill, #03035e);
transform: translateY(-2px);
}
.vote_icon[data-vote-down]:hover {
fill: var(--ms217-vote-down-hover-fill, #7f1d1d);
transform: translateY(2px);
}
.vote_icon[data-vote-up].is-upvoted {
fill: var(--ms217-vote-up-active-icon, #4353ff);
}
.vote_icon[data-vote-down].is-downvoted {
fill: var(--ms217-vote-down-active-icon, #e11d48);
}
.vote_count[data-vote-state="up"] {
color: var(--ms217-vote-up-active-count, #4353ff);
}
.vote_count[data-vote-state="down"] {
color: var(--ms217-vote-down-active-count, #e11d48);
}
</style>More scripts in Webflow CMS