Dynamic list with voting button and counter

Hi there,

Is the following possible:

I need a list with for each item a voting counter and voting button.
And then, when a user votes on a item the list should sort based on what item has the most votes.

So for example:

<ul>
    <li>
        <div>
            <span>Amount of votes 10</span>
            <button>+</button>
        </div>
    </li>
    <li>
        <div>
            <span>Amount of votes 5</span>
            <button>+</button>
        </div>
    </li>
    <li>
        <div>
            <span>Amount of votes 1</span>
            <button>+</button>
        </div>
    </li>
</ul>

So if 20 users would click the last item the list would re-arrange and it would become the first item.

Also I want to limit the amount of clicks for each button in each list item to 1. So users could not keep clicking the same item to prevent someone voting 10 times on 1 item.

If possible, how do I do this?

How many users and how many items are there? How many votes do you expect each item to have?
(talking about the order of magnitude here: is it more like 10, 100, 1000, 10000, …, 10e9?)

I’m asking because the solutions would be different.

For example if you expect something like 500 items for 20 users, it might be better to store the votes in the items.

On the other hand, if you expect something like 20 items for 500 users, it would probably be better to store the votes in the users and only leave an aggregate count in the items…

  • generally 100/200 visitors of the page will be voting (they just receive the page url, so no login needed)
  • list will be around 200 items
  • for each list item only 1 vote allowed per user

its for a DJ that in advance of the party will send a url to the visitors. List will be music songs. So he knows what songs will be popular during party.

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.

Thanx for the suggestions. This is pretty advance for me but I tried your code but I get an error:

[Tue Feb 20 21:39:07 2024] 127.0.0.1:60181 Closing
[Tue Feb 20 21:39:07 2024] Whoops\Exception\ErrorException: Undefined variable $songs in /Users/michielvos/Documents/Development/djaccord/site/templates/default.php:41
Stack trace:
#0 /Users/michielvos/Documents/Development/djaccord/site/templates/default.php(41): Whoops\Run->handleError(2, 'Undefined varia...', '/Users/michielv...', 41)
#1 /Users/michielvos/Documents/Development/djaccord/kirby/src/Filesystem/F.php(425): include('/Users/michielv...')
#2 /Users/michielvos/Documents/Development/djaccord/kirby/src/Filesystem/F.php(364): Kirby\Filesystem\F::loadIsolated('/Users/michielv...', Array)
#3 /Users/michielvos/Documents/Development/djaccord/kirby/src/Filesystem/F.php(372): Kirby\Filesystem\F::Kirby\Filesystem\{closure}()
#4 /Users/michielvos/Documents/Development/djaccord/kirby/src/Toolkit/Tpl.php(36): Kirby\Filesystem\F::load('/Users/michielv...', NULL, Array)
#5 /Users/michielvos/Documents/Development/djaccord/kirby/src/Template/Template.php(163): Kirby\Toolkit\Tpl::load('/Users/michielv...', Array)
#6 /Users/michielvos/Documents/Development/djaccord/kirby/src/Cms/Page.php(1017): Kirby\Template\Template->render(Array)
#7 /Users/michielvos/Documents/Development/djaccord/kirby/src/Cms/App.php(730): Kirby\Cms\Page->render(Array)
#8 /Users/michielvos/Documents/Development/djaccord/kirby/src/Cms/App.php(745): Kirby\Cms\App->io(Object(Kirby\Exception\NotFoundException))
#9 /Users/michielvos/Documents/Development/djaccord/kirby/src/Cms/App.php(1189): Kirby\Cms\App->io(NULL)
#10 /Users/michielvos/Documents/Development/djaccord/index.php(5): Kirby\Cms\App->render()
#11 /Users/michielvos/Documents/Development/djaccord/kirby/router.php(14): require('/Users/michielv...')
#12 {main}

Any suggestions?

The error is thrown in the default template. Where have you added the code provided by @rasteiner?

My bad.

I have changed the files to the default page
So

site/controllers/default.php
site/templates/default.php
assets/js/votes.js
site/templates/default.json.php

No errors but the page is nothing rendering anything except my title.

Also, no errors in the developer console
list renders empty:

Screenshot 2024-02-20 at 22.07.42

What’s you content structure, where is the songlist, you are on the home page, so does the homepage actually have children?

My fault I misread.

I don’t want the songs to be subpages.
I just want a list with songs on 1 page.
In list format it should be manageable in the CMS.
So in CMS user fills in a list on 1 specific page with all kind of songs. And on front-end users can upvote songs.
I have default content structure from the empty kit.

So how are the songs stored? In a structure field?

The solution with the subpages is the easiest to implement

If you have the song titles in one page and you want to update the results, that means you would have to write to the same file over and over again, which is error prone.

In that case I’d recommend to store the results somewhere else.

Hmmmm, does that mean that CMS user would have to click to a individual sub page, add a song, go back, go to another sub page, add a song and so and so forth ? That would not be user friendly

It’s of course possible to have all the songs saved as list in a single page. You could use the stucture field for this. But, in my opinion, that would make stuff more complex, not less.
It means you have to handle some things yourself: when someone votes you’d have to update “the whole list” (because it’s all stored together), so you have to read the list, find the song to update (via some kind of unique id), update that song, put it back into the list, save the list. All while making sure nobody else is voting on any of the songs at the same time.
If it’s pages, most of this is handled by Kirby (all you do is call increment()).

If the problem is about the UI in the panel, you can streamline “creating” pages with the create setting in the page blueprint:

site/blueprints/pages/default.yml

title: Default page
sections:
  songs:
    type: pages
    template: song

site/blueprints/pages/song.yml

title: Song

create:
  title:
    label: Song name
  fields:
    - artist
  redirect: false
  status: listed

fields:
  votes: 
    type: number
  artist:
    type: text

PS:

In Kirby, “page” is just a metonymy for “entity”, we call it like that because it’s what people normally use it for. But in the end, it’s just a txt file in a folder. What that actually represents: a page, a song, a car, a person, or whatever, gets defined by the use you make of it.

So it wouldn’t be “add a subpage, then add the song to the subpage”, because the subpage is the song. And with the create option above you don’t even see the contents of that subpage.

@rasteiner it is rendering now only the plus buttons don’t change the vote count.

I added the 2 blueprints but apart from the list rendering when filled in CMS there is no dynamic vote count going on when clicking on the buttons on the page