9 Commits
1.0.0 ... 1.3.2

Author SHA1 Message Date
269decb110 fix minor styling issue
All checks were successful
release-tag / release-image (push) Successful in 4m46s
2024-07-12 11:25:37 +02:00
5074455dac styling
All checks were successful
release-tag / release-image (push) Successful in 5m1s
2024-07-12 11:13:10 +02:00
d4cf9a53dd export results to csv
All checks were successful
release-tag / release-image (push) Successful in 5m49s
2024-07-11 22:00:22 +02:00
b1891fb51d Change styling (Felix) 2024-07-11 19:42:07 +02:00
Matthias Weber
5ba472db42 add categories to evaluation
All checks were successful
release-tag / release-image (push) Successful in 4m49s
2024-07-10 21:52:48 +02:00
Matthias Weber
566183dc4a make somewhat anonymous, improve evaluation
All checks were successful
release-tag / release-image (push) Successful in 6m27s
2024-07-10 21:31:55 +02:00
e4b2d04c7b tidy up action 2024-07-03 18:13:16 +02:00
8109f121cb run build action online on new tag (version) 2024-07-03 18:10:08 +02:00
b1ba54194f remove jquery dependency
Some checks failed
release-tag / release-image (push) Has been cancelled
2024-07-03 17:06:25 +02:00
8 changed files with 197 additions and 165 deletions

View File

@@ -1,7 +1,9 @@
name: release-tag name: release-tag
on: on:
push push:
tags:
- '*'
jobs: jobs:
release-image: release-image:
@@ -21,7 +23,7 @@ jobs:
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
with: # replace it with your local IP with:
config-inline: | config-inline: |
[registry."git.matsewe.de"] [registry."git.matsewe.de"]
http = true http = true
@@ -30,7 +32,7 @@ jobs:
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
registry: git.matsewe.de # replace it with your local IP registry: git.matsewe.de
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
@@ -48,6 +50,6 @@ jobs:
platforms: | platforms: |
linux/amd64 linux/amd64
push: true push: true
tags: | # replace it with your local IP and tags tags: |
git.matsewe.de/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} git.matsewe.de/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.matsewe.de/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} git.matsewe.de/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}

View File

@@ -1,9 +1,8 @@
import app.models as models import app.models as models
from sqlalchemy import func, and_
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from starlette_context import context
from starlette_context.header_keys import HeaderKeys
from sqlalchemy.sql import text
import pickle
def get_songs_and_vote_for_session(db, session_name) -> list[models.Song]: def get_songs_and_vote_for_session(db, session_name) -> list[models.Song]:
session_entry = activate_session(db, session_name) session_entry = activate_session(db, session_name)
@@ -17,19 +16,31 @@ def get_songs_and_vote_for_session(db, session_name) -> list[models.Song]:
return songs_and_votes return songs_and_votes
def get_all_songs_and_votes(db) -> list:
res = db.execute(text("""SELECT
songs.og_artist,
songs.aca_artist,
songs.title,
songs.url,
songs.categories,
COUNT(vote) FILTER (where vote = -1) as "nein" ,
COUNT(vote) FILTER (where vote = 0) as "neutral",
COUNT(vote) FILTER (where vote = 1) as "ja"
FROM votes INNER JOIN songs ON votes.song_id = songs.id
GROUP BY song_id ORDER BY song_id
""")).fetchall()
def get_all_songs_and_votes(db) -> dict[int, dict[int, int]]: def process_row(r):
_v = db.query(models.Vote.song_id, models.Vote.vote, func.count( e = dict(r._mapping)
models.Vote.song_id)).group_by(models.Vote.song_id, models.Vote.vote).all() c = pickle.loads(e["categories"])
c = {k : bool(v) for k, v in c.items()}
e["categories"] = c
votes = {} return e
for v in _v: res = [process_row(r) for r in res]
if v[0] not in votes:
votes[v[0]] = {-1: 0, 0: 0, 1: 0}
votes[v[0]][v[1]] = v[2]
return votes return res
def create_song(db, def create_song(db,
@@ -101,33 +112,25 @@ def create_or_update_comment(db, song_id, session_name, comment):
def activate_session(db, session_name): def activate_session(db, session_name):
ip = context.data[HeaderKeys.forwarded_for]
user_agent = context.data[HeaderKeys.user_agent]
session_entry = db.query(models.Session).filter(and_( session_entry = db.query(models.Session).filter(
models.Session.session_name == session_name)).first() # , models.Session.ip == ip, models.Session.user_agent == user_agent models.Session.session_name == session_name).first() # , models.Session.ip == ip, models.Session.user_agent == user_agent
if session_entry: if session_entry:
if ip not in session_entry.ips:
session_entry.ips.append(ip)
session_entry.active = True session_entry.active = True
else: else:
session_entry = models.Session( session_entry = models.Session(
session_name=session_name, active=True, ips=[ip]) # , ip=ip, user_agent=user_agent session_name=session_name, active=True) # , ip=ip, user_agent=user_agent
db.add(session_entry) db.add(session_entry)
flag_modified(session_entry, "active") flag_modified(session_entry, "active")
flag_modified(session_entry, "ips")
db.commit() db.commit()
return session_entry return session_entry
def deactivate_session(db, session_name): def deactivate_session(db, session_name):
ip = context.data[HeaderKeys.forwarded_for] session_entry = db.query(models.Session).filter(
user_agent = context.data[HeaderKeys.user_agent] models.Session.session_name == session_name).first() # , models.Session.ip == ip, models.Session.user_agent == user_agent
session_entry = db.query(models.Session).filter(and_(
models.Session.session_name == session_name)).first() # , models.Session.ip == ip, models.Session.user_agent == user_agent
if session_entry: if session_entry:
session_entry.active = False session_entry.active = False
flag_modified(session_entry, "active") flag_modified(session_entry, "active")

View File

@@ -16,6 +16,8 @@ from starlette.middleware import Middleware
from starlette_context import plugins from starlette_context import plugins
from starlette_context.middleware import RawContextMiddleware from starlette_context.middleware import RawContextMiddleware
from hashlib import sha384
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
if os.path.isfile("/tmp/first_run") and (os.environ.get("RELOAD_ON_FIRST_RUN", "").lower() == "true"): if os.path.isfile("/tmp/first_run") and (os.environ.get("RELOAD_ON_FIRST_RUN", "").lower() == "true"):
@@ -58,7 +60,7 @@ async def vote(request: Request, session_id: str | None = None , unordered: bool
db: Session = Depends(get_db)) -> HTMLResponse: db: Session = Depends(get_db)) -> HTMLResponse:
if not session_id: if not session_id:
session_id = user["sub"] session_id = sha384(str.encode(user["sub"])).hexdigest() #CryptContext(schemes=["bcrypt"], deprecated="auto").hash(user["sub"])
#print(user) #print(user)

View File

@@ -32,7 +32,6 @@ class Session(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
session_name: Mapped[str] session_name: Mapped[str]
active: Mapped[bool] active: Mapped[bool]
ips: Mapped[list[str]]
first_seen: Mapped[datetime] = mapped_column(server_default=func.now()) first_seen: Mapped[datetime] = mapped_column(server_default=func.now())
last_seen: Mapped[Optional[datetime] last_seen: Mapped[Optional[datetime]
] = mapped_column(onupdate=func.now()) ] = mapped_column(onupdate=func.now())

View File

@@ -3,12 +3,14 @@ import numpy as np
import re import re
import requests import requests
import os import os
from fastapi import APIRouter, Security, Depends from fastapi import APIRouter, Security, Depends, HTTPException, Response
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db, engine, Base from app.database import get_db, engine, Base
from app.security import get_current_user from app.security import get_current_user
from app.crud import create_song, get_setting, set_setting from app.crud import create_song, get_setting, set_setting, get_all_songs_and_votes
from typing import Any
router = APIRouter( router = APIRouter(
prefix="/admin", prefix="/admin",
@@ -79,11 +81,12 @@ async def create_upload_file(include_non_singable: bool = False, db: Session = D
category_names = list(song_list.iloc[0][8:17]) category_names = list(song_list.iloc[0][8:17])
for i, row in song_list[1:].iterrows(): for i, row in song_list[1:].iterrows():
if (row[17] == "nein") and not include_non_singable:
continue
row = np.array(row) row = np.array(row)
if (row[17] == "nein") and not include_non_singable:
continue
if not row[2]: # no title if not row[2]: # no title
continue continue
@@ -126,3 +129,16 @@ async def toggle_veto_mode(db: Session = Depends(get_db)) -> bool:
else: else:
set_setting(db, "veto_mode", True) set_setting(db, "veto_mode", True)
return True return True
@router.get("/results")
async def get_evaluation(format: str = "json", db = Depends(get_db)) -> Any:
res = get_all_songs_and_votes(db)
if format == "json":
return JSONResponse(content=res)
elif format == "csv":
df = pd.json_normalize(res)
df.columns = [c.split(".")[-1] for c in df.columns]
return Response(content=df.to_csv(), media_type="application/csv", headers={'Content-Disposition': 'attachment; filename="results.csv"'})
else:
raise HTTPException(status_code=404, detail="format must be json or csv")

View File

@@ -1,11 +1,10 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import app.models as models
from app.database import get_db from app.database import get_db
from app.schemas import Song from app.schemas import Song
from app.crud import get_songs_and_vote_for_session, create_or_update_vote, get_all_songs_and_votes, create_or_update_comment from app.crud import get_songs_and_vote_for_session, create_or_update_vote, create_or_update_comment
router = APIRouter( router = APIRouter(
prefix="/songs", prefix="/songs",
@@ -28,6 +27,3 @@ async def comment(song_id: str, session_id: str, comment: str, db: Annotated[Ses
create_or_update_comment(db, song_id, session_id, comment) create_or_update_comment(db, song_id, session_id, comment)
#create_or_update_vote(db, song_id, session_id, vote) #create_or_update_vote(db, song_id, session_id, vote)
@router.get("/evaluation")
async def get_evaluation(db: Annotated[Session, Depends(get_db)] = None) -> dict[int, dict[int, int]]:
return get_all_songs_and_votes(db)

View File

@@ -52,6 +52,24 @@
font-family: Fira Sans, Helvetica, Arial, sans-serif; font-family: Fira Sans, Helvetica, Arial, sans-serif;
} }
html {
background: var(--color-background);
}
header {
background-color: var(--color-choriosity-red);
height: 80px;
width: 100%;
display: flex;
align-items: center;
margin-bottom: 40px;
}
::selection {
background: var(--color-choriosity-red);
color: var(--color-white-100);
}
.vote-buttons, .vote-buttons,
.cover-container, .cover-container,
.categories { .categories {
@@ -73,7 +91,6 @@
} }
} }
.text { .text {
padding: 0.3em; padding: 0.3em;
margin-bottom: 0.7em; margin-bottom: 0.7em;
@@ -87,6 +104,10 @@
.song { .song {
background-color: var(--color-choriosity-red--light); background-color: var(--color-choriosity-red--light);
background-color: var(--color-white-100);
/*border: 1px solid var(--color-choriosity-red);*/
border: 1px solid hsl(calc(var(--hue) * 360), 100%, 40%);
box-shadow: 4px 4px 8px var(--color-white-90);
padding: 0.4em; padding: 0.4em;
border-radius: 0.5em; border-radius: 0.5em;
width: 30em; width: 30em;
@@ -95,11 +116,10 @@
float: left; float: left;
} }
.cover-container { .cover-container {
position: relative; position: relative;
width: 10.67em; width: 10.67em;
height: 6em; height: 6.7em;
float: left; float: left;
margin-right: 1em; margin-right: 1em;
text-align: center; text-align: center;
@@ -119,7 +139,6 @@
transition: .3s ease; transition: .3s ease;
} }
.overlay { .overlay {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -161,9 +180,9 @@
height: 60%; height: 60%;
} }
.vote-buttons { .vote-buttons {
display: inline-block; display: inline-block;
margin-top: 0.7em;
} }
.button { .button {
@@ -211,11 +230,11 @@
} }
.button:not(.selected):not(:hover) { .button:not(.selected):not(:hover) {
background-color: #b0b0b0; background-color: var(--color-white-90);
} }
.button:hover { .button:hover {
filter: drop-shadow(2px 2px 2px) brightness(95%); filter: drop-shadow(2px 2px 2px var(--color-white-70)) brightness(95%);
cursor: pointer; cursor: pointer;
} }
@@ -236,7 +255,6 @@
} }
.categories { .categories {
width: 60%;
overflow: hidden; overflow: hidden;
/*cursor: grab;*/ /*cursor: grab;*/
white-space: nowrap; white-space: nowrap;
@@ -249,6 +267,7 @@
border-radius: 1.2em; border-radius: 1.2em;
padding: 0 0.5em 0 0.5em; padding: 0 0.5em 0 0.5em;
margin-right: 0.4em; margin-right: 0.4em;
margin-top: 0.5em;
display: inline-block; display: inline-block;
color: white; color: white;
max-width: 10em; max-width: 10em;
@@ -257,9 +276,12 @@
/*color-mix(in srgb, var(--main-color) 60%, transparent);*/ /*color-mix(in srgb, var(--main-color) 60%, transparent);*/
} }
.vote-buttons { /*.vote-buttons {
margin-top: 0.5em; margin-top: 1em;
} width: 100%;
display: inline-flex;
justify-content: center;
}*/
.song-title, .song-title,
.song-artist { .song-artist {
@@ -273,28 +295,44 @@
} }
h1 { h1 {
padding: 0.1em; font-size: 2rem;
padding-left: 0.2em;
margin-bottom: 1rem;
font-size: 1.25rem;
background-color: var(--color-choriosity-red);
color: white; color: white;
/*border-bottom: 0.3rem solid var(--color-choriosity-red--light);*/
clear: both; clear: both;
} }
#songs h1 .color { h1#title {
border: 0.2rem solid color-mix(in srgb, hsl(calc(var(--hue) * 360), 100%, 40%) 50%, white); margin-top: 30px;
background-color: hsl(calc(var(--hue) * 360), 100%, 40%);
border-radius: 0.5em;
margin-right: 1em;
} }
#songs h1 { h2 {
border-left: 0.3em solid white; padding: 0.1em;
box-shadow: -1em 0px 0px 0px hsl(calc(var(--hue) * 360), 100%, 40%); padding-left: 0.2em;
margin-left: 1em; margin-bottom: 1rem;
padding-left: 0.3em; padding-top: 1.5rem;
font-size: 1.25rem;
clear: both;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
h2:after {
content: '';
border-top: 2px solid hsl(calc(var(--hue) * 360), 100%, 40%);
flex: 1 0 20px;
margin: 0 0 0 10px;
}
h2:before {
content: '';
display: inline-block;
width: 15px;
height: 15px;
border-radius: 7.5px;
background-color: hsl(calc(var(--hue) * 360), 100%, 40%);
margin-right: 0.3em;
margin-left: 0.3em;
} }
#yt-player, #yt-player,
@@ -335,17 +373,9 @@ h1 {
width: 1.5em; width: 1.5em;
} }
#title {
margin-top: 1em;
font-size: 2rem;
/*line-height: 2rem;*/
}
#title-logo { #title-logo {
margin-top: -0.7em;
margin-bottom: -0.8em;
vertical-align: middle; vertical-align: middle;
height: 3em; height: 6rem;
filter: drop-shadow(0 0 1px var(--color-choriosity-red--medium)); filter: drop-shadow(0 0 1px var(--color-choriosity-red--medium));
margin-right: 1em; margin: 30px 1em 0 1em;
} }

View File

@@ -27,37 +27,9 @@
{% endif %} {% endif %}
<script src="https://open.spotify.com/embed/iframe-api/v1" async></script> <script src="https://open.spotify.com/embed/iframe-api/v1" async></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
<script type="text/javascript"> <script type="text/javascript">
const session_id = "{{ session_id }}"; const session_id = "{{ session_id }}";
function activate_session() {
$.ajax({
url: "/session/" + session_id,
method: "PUT"
})
}
function deactivate_session() {
$.ajax({
url: "/session/" + session_id,
method: "DELETE"
})
}
$(window).on("load", activate_session);
$(window).on("beforeunload", deactivate_session);
$(window).on("unload", deactivate_session);
$(window).on("pagehide", deactivate_session);
$(document).on('visibilitychange', function () {
if (document.visibilityState == 'hidden') {
deactivate_session();
} else {
activate_session()
}
});
var spotify_embed_controller; var spotify_embed_controller;
window.onSpotifyIframeApiReady = (IFrameAPI) => { window.onSpotifyIframeApiReady = (IFrameAPI) => {
@@ -75,12 +47,15 @@
var is_playing = -1; var is_playing = -1;
function stop() { function stop() {
$("#song-" + is_playing + " .cover-container .overlay img").attr("src", "/static/play.svg"); if (is_playing !== -1) {
document.querySelector("#song-" + is_playing + " .cover-container .overlay img").setAttribute("src", "/static/play.svg");
}
document.querySelector("#yt-player").style.display = "none";
document.querySelector("#spotify-player").style.display = "none";
document.querySelector("#close-player").style.display = "none";
document.querySelector("#yt-player").innerHTML = "";
$("#yt-player").css("display", "none");
$("#spotify-player").css("display", "none");
$("#close-player").css("display", "none");
$("#yt-player").html("");
spotify_embed_controller.pause(); spotify_embed_controller.pause();
is_playing = -1; is_playing = -1;
} }
@@ -93,12 +68,13 @@
is_playing = song_id; is_playing = song_id;
$("#song-" + song_id + " .cover-container .overlay img").attr("src", "/static/stop.svg"); document.querySelector("#song-" + song_id + " .cover-container .overlay img").setAttribute("src", "/static/stop.svg");
document.querySelector("#yt-player").style.display = "flex";
document.querySelector("#close-player").style.display = "block";
$("#yt-player").css("display", "flex");
$("#close-player").css("display", "block");
iframe_code = '<iframe src="https://www.youtube.com/embed/' + yt_id + '?autoplay=1" title="" width="640" height="360" frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen></iframe>'; iframe_code = '<iframe src="https://www.youtube.com/embed/' + yt_id + '?autoplay=1" title="" width="640" height="360" frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen></iframe>';
$("#yt-player").html(iframe_code); document.querySelector("#yt-player").innerHTML = iframe_code;
} }
} }
@@ -110,10 +86,10 @@
is_playing = song_id; is_playing = song_id;
$("#song-" + song_id + " .cover-container .overlay img").attr("src", "/static/stop.svg"); document.querySelector("#song-" + song_id + " .cover-container .overlay img").setAttribute("src", "/static/stop.svg");
$("#spotify-player").css("display", "flex"); document.querySelector("#spotify-player").style.display = "flex";
$("#close-player").css("display", "block"); document.querySelector("#close-player").style.display = "block";
spotify_embed_controller.loadUri("spotify:track:" + spfy_id); spotify_embed_controller.loadUri("spotify:track:" + spfy_id);
spotify_embed_controller.play(); spotify_embed_controller.play();
} }
@@ -124,59 +100,62 @@
window.open(url, '_blank').focus(); window.open(url, '_blank').focus();
} }
function vote(song_id, vote) { async function vote(song_id, vote) {
$.ajax({
url: "/songs/" + song_id + "/vote?" + $.param({ session_id: session_id, vote: vote }),
method: "POST",
success: function (data, textStatus) {
no_button = $("#song-" + song_id).find(".button-no")
yes_button = $("#song-" + song_id).find(".button-yes")
neutral_button = $("#song-" + song_id).find(".button-neutral")
no_button.removeClass("selected")
yes_button.removeClass("selected")
neutral_button.removeClass("selected")
switch (vote) {
case 0:
neutral_button.addClass("selected")
{% if veto_mode %}
$("#song-" + song_id).removeClass("not_singable")
{% endif %}
break;
case 1:
yes_button.addClass("selected")
{% if veto_mode %}
$("#song-" + song_id).removeClass("not_singable")
{% endif %}
break;
case -1:
no_button.addClass("selected")
{% if veto_mode %}
$("#song-" + song_id).addClass("not_singable")
{% endif %}
break;
default:
break;
}
response = await fetch(
"/songs/" + song_id + "/vote?session_id=" + encodeURIComponent(session_id) + "&vote=" + vote, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
} }
}); });
if (response.ok) {
no_button = document.querySelector("#song-" + song_id + " .button-no");
yes_button = document.querySelector("#song-" + song_id + " .button-yes");
neutral_button = document.querySelector("#song-" + song_id + " .button-neutral");
no_button.classList.remove("selected")
yes_button.classList.remove("selected")
neutral_button.classList.remove("selected")
switch (vote) {
case 0:
neutral_button.classList.add("selected")
{% if veto_mode %}
document.querySelector("#song-" + song_id).classList.remove("not_singable")
{% endif %}
break;
case 1:
yes_button.classList.add("selected")
{% if veto_mode %}
document.querySelector("#song-" + song_id).classList.remove("not_singable")
{% endif %}
break;
case -1:
no_button.classList.add("selected")
{% if veto_mode %}
document.querySelector("#song-" + song_id).classList.add("not_singable")
{% endif %}
break;
default:
break;
}
}
} }
{% if veto_mode %} {% if veto_mode %}
function updateComment(song_id, el) { async function updateComment(song_id, el) {
comment = el.value comment = el.value
$.ajax({
url: "/songs/" + song_id + "/comment?" + $.param({ session_id: session_id, comment: comment }), response = await fetch(
method: "POST" "/songs/" + song_id + "/comment?session_id=" + encodeURIComponent(session_id) + "&comment=" + encodeURIComponent(comment), {
}) method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
} }
{% endif %} {% endif %}
</script> </script>
@@ -189,15 +168,20 @@
abgeben. abgeben.
</div> </div>
{% else %} {% else %}
<h1 id="title"><img id="title-logo" src="https://choriosity.de/assets/images/logo-choriosity_weiss.svg"> Liederwahl</h1> <header>
<div class="text">Du kannst die Liederwahl jederzeit unterbrechen und zu einem späteren Zeitpunkt weitermachen. Mit einem Klick auf das Thumbnail kannst du das Lied abspielen. <img id="title-logo" src="https://choriosity.de/assets/images/logo-choriosity_weiss.svg">
<h1 id="title">Liederwahl</h1>
</header>
<div class="text">Du kannst die Liederwahl jederzeit unterbrechen und zu einem späteren Zeitpunkt weitermachen. Mit
einem Klick auf das Thumbnail kannst du das Lied abspielen.
</div> </div>
{% endif %} {% endif %}
<div id="songs"> <div id="songs">
{% for main_category, songs in songs_by_category.items() %} {% for main_category, songs in songs_by_category.items() %}
<h1 style="--hue: {{ all_categories[main_category] / all_categories|length }};">{{ main_category }}</h1> <h2 style="--hue: {{ all_categories[main_category] / all_categories|length }};">{{ main_category }}</h2>
{% for song in songs -%} {% for song in songs -%}
<div class="song{% if (song.vote == -1) or (not song.vote and not song.singable) %} not_singable{% endif %}" <div style="--hue: {{ all_categories[main_category] / all_categories|length }};" class="song{% if (song.vote == -1) or (not song.vote and not song.singable) %} not_singable{% endif %}"
id="song-{{ song.id }}"> id="song-{{ song.id }}">
<div class="cover-container"> <div class="cover-container">
<img src="{{ song.thumbnail }}" class="cover"> <img src="{{ song.thumbnail }}" class="cover">
@@ -213,7 +197,7 @@
<div class="categories" id="container">{% for category_name, is_in_category in song.categories.items() %}{% <div class="categories" id="container">{% for category_name, is_in_category in song.categories.items() %}{%
if is_in_category %}<span style="--hue: {{ all_categories[category_name] / all_categories|length }};">{{ if is_in_category %}<span style="--hue: {{ all_categories[category_name] / all_categories|length }};">{{
category_name }}</span>{% category_name }}</span>{%
endif %}{% endfor %}<span style="--main-color: transparent;">&nbsp;</span> endif %}{% endfor %}<span style="--main-color: transparent; --hue: transparent;">&nbsp;</span>
</div> </div>
<div class="vote-buttons"> <div class="vote-buttons">
<div class="button button-no {% if (song.vote == -1) or (not song.vote and not song.singable) %}selected{% endif %}" <div class="button button-no {% if (song.vote == -1) or (not song.vote and not song.singable) %}selected{% endif %}"