Without some kind of user authentication, or some kind of personal token, there isn’t really a “secure” way to make sure everyone just votes once.
Since the stakes aren’t that high (it’s not election night), though, you could decide to have it the easy way and simply store the list of already voted items on the client-side and in a session cookie. This can be worked around by clearing “localStorage”, changing device, opening an “incognito window”, the browser console or really anything that can request stuff on the web.
The controller
If the songs are pages in kirby, you can sort them by votes. Since we’ll use this list of sorted songs in multiple places, it might be best to generate it in a controller. Let’s assume you’ll have a songlist
template, its child pages are the single songs.
site/controllers/songlist.php
<?php
return function($page) {
return [
'songs' => $page->children()->sortBy('votes', 'desc')
];
};
The template
You’ll need a template for the list of songs. This page will contain the list, already sorted by votes, and will run the client side JS script that handles the button clicks.
site/templates/songlist.php
<?php snippet('header'); ?>
<ul id="songs">
<?php foreach($songs as $song): ?>
<li data-id="<?= $song->id() ?>">
<div>
<span>Amount of votes <output>10</output></span>
<button>+</button>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php js('assets/js/votes.js') ?>
<?php snippet('footer'); ?>
The frontend script
This script handles the user interaction in the browser. It also “prevents” users from voting a song more than once.
assets/js/votes.js
// first, load the users state into memory from localStorage
const votes = JSON.parse(localStorage.getItem('votes')) ?? {};
// get a reference to all list items
const items = Array.from(document.querySelectorAll('#songs li'));
// disable buttons, add event listener to buttons, index items by id
const index = new Map();
for(const li of items) {
const id = li.dataset.id;
const button = li.querySelector('button');
index.set(id, li);
if(id in votes) button.disabled = true;
button.addEventListener('click', () => vote(id, button));
}
async function vote(id, button) {
// if someone's calling this method again somehow, block it here
if(id in votes) return;
// user should stop clicking this
button.disabled = true;
// send a request to vote for the song
const req = await fetch(location, {
method: 'post',
data: JSON.stringify({id}),
headers: {'Content-Type': 'application/json'}
});
// if there was an error, stop.
if(req.status !== 200) {
throw "Backend error: " + (await req.text());
}
// if all went right, receive all the updated numbers
// (other people might have voted in the meantime)
// don't sort the list immediately, though: that would be quite jarring
const songs = await req.json();
for(const song of songs) {
index.get(song.id)?.querySelector('output').innerText = song.votes;
}
// also update the local list of already voted songs and update localStorage
votes[id] = 1;
localStorage.setItem('votes', JSON.stringify(votes));
}
The backend
A numeric field (such as the “votes” field present in each song page), can be incremented by the $page->increment()
method, for which you need “page update” permissions, therefore the not logged in users need code to impersonate “kirby” to update the pages. We would also store the voted items in a user session, this prevents people from voting ny just opening multiple browser tabs to the same voting page.
You need to execute that code somehow, so let’s create a JSON content representation that we’ll use as an API endpoint. It, too, will automatically receive the sorted list of songs from the controller, since it’s only a different representation of the same page:
site/templates/songlist.json.php
<?php
function voteOn($songs, $id) {
// put the song id into a session,
// so people can't just vote the same song just by opening multiple tabs
$session = $kirby->session(['long' => true]);
$alreadyVoted = $session->get('votes') ?? [];
if($alreadyVoted[$id] ?? false) return $songs;
if($voted = $songs->get($id)) {
$updated = $kirby->impersonate('kirby', fn() =>
$voted->increment('votes')
);
$alreadyVoted[$id] = true;
$session->set('votes', $alreadyVoted);
// remove the original song from the collection,
// so we can add the updated one
$songs->remove($voted);
$songs->add($updated);
}
return $songs;
}
if(R::is('POST') and $id = get('id')) {
$songs = voteOn($songs, $id);
}
echo json_encode($songs->values(fn($song) => [
'id' => $song->id(),
'votes' => $song->votes()->toInt(),
]));
I’ve written this here on the forum, without testing anything. So it will probably have some mistakes, but it should give you an idea on how you could approach the project in Kirby.