Compare commits

...

17 Commits

Author SHA1 Message Date
e66e4a6c19 Fix error on no owned entries 2023-04-28 13:39:20 +02:00
08d0e5557b Update docker-compose to new version 2023-04-28 13:13:47 +02:00
a987dfc9d2 Merge branch 'legacy' of github.com:PhoenixTwoFive/karaoqueue into legacy 2023-04-28 13:03:57 +02:00
04511a91a1 Freeze versions to avoid Flask 2.3 deprecation problems 2023-04-28 13:03:53 +02:00
7da0dc19dc Merge pull request #57 from PhoenixTwoFive/feature/legacy/56-buttons-for-copying-to-clipboard-in-entry-list
Feature/legacy/56 buttons for copying to clipboard in entry list
2023-04-28 12:56:36 +02:00
d0d8e41b48 Indicate copyability of table text
Indicate copyability on click of table contents by highlighting
hovered element
2023-04-28 12:53:03 +02:00
f2b4611ea6 Remove problematic tooltip and debug log 2023-04-28 12:52:03 +02:00
b76fcfd8e4 Add Copy functionality and corresponding toasts 2023-04-28 12:40:28 +02:00
c9cbd24569 Merge pull request #55 from PhoenixTwoFive/feature/legacy/53-löschung-von-eigenen-einträgen-erlauben
Feature/legacy/53 löschung von eigenen einträgen erlauben
2023-04-27 00:18:37 +02:00
add528fb80 Löschung eigener Enträge implementiert. 2023-04-26 19:20:21 +02:00
865df5d588 Implement EventID to scope ClientIDs and Entry IDs
Implement an EventID saved in settings. Currently this is used to scope
clientIDs and entryIDs to an event. The client checks the event currently going on on
the server, and discards its localstorage (containing the clientID) if
it has changed
2023-04-26 18:08:03 +02:00
adebf35d08 Update lint.yaml
Remove action run on every push
2023-04-25 16:52:36 +02:00
d2caaac4bc Codecheck (#54)
* Add GitHub Action

* Add Linting

* Add .editorconfig
2023-04-25 16:46:43 +02:00
58dd0dd93b Add devcontainer.json setup 2023-04-02 17:21:56 +02:00
10717e753b Update Compose File 2023-03-31 20:58:47 +02:00
f32f02dc44 Fix Sorting in List view 2023-03-31 20:57:34 +02:00
3921a9ea76 Update docker-Compose 2023-03-30 22:56:09 +02:00
17 changed files with 569 additions and 262 deletions

View File

@ -0,0 +1,32 @@
{
"image": "mcr.microsoft.com/devcontainers/universal:2",
"hostRequirements": {
"cpus": 4
},
"waitFor": "onCreateCommand",
"updateContentCommand": "pip install -r requirements.txt",
"postCreateCommand": "",
"postAttachCommand": {
"server": "flask --debug run"
},
"portsAttributes": {
"5000": {
"label": "Application",
"onAutoForward": "openPreview"
}
},
"customizations": {
"codespaces": {
"openFiles": [
"templates/index.html"
]
},
"vscode": {
"extensions": [
"ms-python.python"
]
}
},
"forwardPorts": [5000]
}

36
.editorconfig Normal file
View File

@ -0,0 +1,36 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
[*.md]
trim_trailing_whitespace = true
[*.py]
indent_size = 4
[*.js]
indent_size = 2
[*.html]
indent_size = 2
[*.css]
indent_size = 2
[*.scss]
indent_size = 2
[*.yaml]
indent_size = 2
[*.yml]
indent_size = 2

3
.flake8 Normal file
View File

@ -0,0 +1,3 @@
[flake8]
ignore = E501
max-line-length = 120

23
.github/workflows/lint.yaml vendored Normal file
View File

@ -0,0 +1,23 @@
name: Lint
on: [pull_request]
jobs:
flake8_py3:
runs-on: ubuntu-latest
steps:
- name: Setup Python
uses: actions/setup-python@v4.6.0
with:
python-version: '3.10'
architecture: x64
- name: Checkout PyTorch
uses: actions/checkout@master
- name: Install flake8
run: pip install flake8
- name: Run flake8
uses: suo/flake8-github-action@releases/v1
with:
checkName: 'flake8_py3' # NOTE: this needs to be the same as the job name
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

16
.vscode/settings.json vendored
View File

@ -1,3 +1,17 @@
{ {
"python.pythonPath": "/usr/bin/python" "python.pythonPath": "/usr/bin/python",
"python.testing.unittestArgs": [
"-v",
"-s",
"./backend/tests",
"-p",
"*test.py"
],
"python.testing.pytestEnabled": false,
"python.testing.unittestEnabled": true,
"python.linting.pylintEnabled": false,
"python.linting.flake8Enabled": true,
"emmet.includeLanguages": {
"django-html": "html"
}
} }

View File

@ -1,5 +1,5 @@
from flask import Flask, render_template, abort, request, redirect, send_from_directory, jsonify from flask import Flask, render_template, abort, request, redirect, send_from_directory, jsonify
from flask.wrappers import Request, Response from flask.wrappers import Response
import helpers import helpers
import database import database
import data_adapters import data_adapters
@ -12,6 +12,7 @@ app = Flask(__name__, static_url_path='/static')
basic_auth = BasicAuth(app) basic_auth = BasicAuth(app)
accept_entries = True accept_entries = True
@app.route("/") @app.route("/")
def home(): def home():
if basic_auth.authenticate(): if basic_auth.authenticate():
@ -39,8 +40,8 @@ def enqueue():
name = request.json['name'] name = request.json['name']
song_id = request.json['id'] song_id = request.json['id']
if request.authorization: if request.authorization:
database.add_entry(name, song_id, client_id) entry_id = database.add_entry(name, song_id, client_id)
return Response('{"status":"OK"}', mimetype='text/json') return Response(f"""{{"status":"OK", "entry_id":{entry_id}}}""", mimetype='text/json')
else: else:
if helpers.get_accept_entries(app): if helpers.get_accept_entries(app):
if not request.json: if not request.json:
@ -54,8 +55,8 @@ def enqueue():
song_id = request.json['id'] song_id = request.json['id']
if database.check_queue_length() < int(app.config['MAX_QUEUE']): if database.check_queue_length() < int(app.config['MAX_QUEUE']):
if database.check_entry_quota(client_id) < int(app.config['ENTRY_QUOTA']): if database.check_entry_quota(client_id) < int(app.config['ENTRY_QUOTA']):
database.add_entry(name, song_id, client_id) entry_id = database.add_entry(name, song_id, client_id)
return Response('{"status":"OK"}', mimetype='text/json') return Response(f"""{{"status":"OK", "entry_id":{entry_id}}}""", mimetype='text/json')
else: else:
return Response('{"status":"Du hast bereits ' + str(database.check_entry_quota(client_id)) + ' Songs eingetragen, dies ist das Maximum an Einträgen die du in der Warteliste haben kannst."}', mimetype='text/json', status=423) return Response('{"status":"Du hast bereits ' + str(database.check_entry_quota(client_id)) + ' Songs eingetragen, dies ist das Maximum an Einträgen die du in der Warteliste haben kannst."}', mimetype='text/json', status=423)
else: else:
@ -95,7 +96,7 @@ def settings_post():
else: else:
abort(400) abort(400)
if theme is not None and theme in helpers.get_themes(): if theme is not None and theme in helpers.get_themes():
helpers.set_theme(app,theme) helpers.set_theme(app, theme)
else: else:
abort(400) abort(400)
if username != "" and username != app.config['BASIC_AUTH_USERNAME']: if username != "" and username != app.config['BASIC_AUTH_USERNAME']:
@ -137,18 +138,18 @@ def songs():
@basic_auth.required @basic_auth.required
def update_songs(): def update_songs():
database.delete_all_entries() database.delete_all_entries()
helpers.reset_current_event_id(app)
status = database.import_songs( status = database.import_songs(
helpers.get_songs(helpers.get_catalog_url())) helpers.get_songs(helpers.get_catalog_url()))
print(status) print(status)
return Response('{"status": "%s" }' % status, mimetype='text/json') return Response('{"status": "%s" }' % status, mimetype='text/json')
@app.route("/api/songs/compl") # type: ignore @app.route("/api/songs/compl") # type: ignore
@nocache @nocache
def get_song_completions(input_string=""): def get_song_completions(input_string=""):
input_string = request.args.get('search', input_string) input_string = request.args.get('search', input_string)
if input_string != "": if input_string != "":
print(input_string)
result = [list(x) for x in database.get_song_completions(input_string=input_string)] result = [list(x) for x in database.get_song_completions(input_string=input_string)]
return jsonify(result) return jsonify(result)
@ -156,10 +157,29 @@ def get_song_completions(input_string=""):
return 400 return 400
@app.route("/api/entries/delete/<entry_id>") @app.route("/api/entries/delete/<entry_id>", methods=['GET'])
@nocache @nocache
@basic_auth.required @basic_auth.required
def delete_entry(entry_id): def delete_entry_admin(entry_id):
if database.delete_entry(entry_id):
return Response('{"status": "OK"}', mimetype='text/json')
else:
return Response('{"status": "FAIL"}', mimetype='text/json')
@app.route("/api/entries/delete/<entry_id>", methods=['POST'])
@nocache
def delete_entry_user(entry_id):
if not request.json:
print(request.data)
abort(400)
client_id = request.json['client_id']
if not helpers.is_valid_uuid(client_id):
print(request.data)
abort(400)
if database.get_raw_entry(entry_id)[3] != client_id: # type: ignore
print(request.data)
abort(403)
if database.delete_entry(entry_id): if database.delete_entry(entry_id):
return Response('{"status": "OK"}', mimetype='text/json') return Response('{"status": "OK"}', mimetype='text/json')
else: else:
@ -176,7 +196,7 @@ def delete_entries():
return return
updates = database.delete_entries(request.json) updates = database.delete_entries(request.json)
if updates >= 0: if updates >= 0:
return Response('{"status": "OK", "updates": '+str(updates)+'}', mimetype='text/json') return Response('{"status": "OK", "updates": ' + str(updates) + '}', mimetype='text/json')
else: else:
return Response('{"status": "FAIL"}', mimetype='text/json', status=400) return Response('{"status": "FAIL"}', mimetype='text/json', status=400)
@ -190,6 +210,7 @@ def mark_sung(entry_id):
else: else:
return Response('{"status": "FAIL"}', mimetype='text/json') return Response('{"status": "FAIL"}', mimetype='text/json')
@app.route("/api/entries/mark_transferred/<entry_id>") @app.route("/api/entries/mark_transferred/<entry_id>")
@nocache @nocache
@basic_auth.required @basic_auth.required
@ -205,7 +226,7 @@ def mark_transferred(entry_id):
@basic_auth.required @basic_auth.required
def set_accept_entries(value): def set_accept_entries(value):
if (value == '0' or value == '1'): if (value == '0' or value == '1'):
helpers.set_accept_entries(app,bool(int(value))) helpers.set_accept_entries(app, bool(int(value)))
return Response('{"status": "OK"}', mimetype='text/json') return Response('{"status": "OK"}', mimetype='text/json')
else: else:
return Response('{"status": "FAIL"}', mimetype='text/json', status=400) return Response('{"status": "FAIL"}', mimetype='text/json', status=400)
@ -215,7 +236,7 @@ def set_accept_entries(value):
@nocache @nocache
def get_accept_entries(): def get_accept_entries():
accept_entries = helpers.get_accept_entries(app) accept_entries = helpers.get_accept_entries(app)
return Response('{"status": "OK", "value": '+str(int(accept_entries))+'}', mimetype='text/json') return Response('{"status": "OK", "value": ' + str(int(accept_entries)) + '}', mimetype='text/json')
@app.route("/api/played/clear") @app.route("/api/played/clear")
@ -233,6 +254,7 @@ def clear_played_songs():
@basic_auth.required @basic_auth.required
def delete_all_entries(): def delete_all_entries():
if database.delete_all_entries(): if database.delete_all_entries():
helpers.reset_current_event_id(app)
return Response('{"status": "OK"}', mimetype='text/json') return Response('{"status": "OK"}', mimetype='text/json')
else: else:
return Response('{"status": "FAIL"}', mimetype='text/json') return Response('{"status": "FAIL"}', mimetype='text/json')
@ -244,6 +266,12 @@ def admin():
return redirect("/", code=303) return redirect("/", code=303)
@app.route("/api/events/current")
@nocache
def get_current_event():
return Response('{"status": "OK", "event": "' + helpers.get_current_event_id(app) + '"}', mimetype='text/json')
@app.before_first_request @app.before_first_request
def activate_job(): def activate_job():
helpers.load_dbconfig(app) helpers.load_dbconfig(app)
@ -257,17 +285,17 @@ def activate_job():
helpers.setup_config(app) helpers.setup_config(app)
@app.after_request @app.after_request
def add_header(response): def add_header(response):
""" """
Add headers to both force latest IE rendering engine or Chrome Frame, Add headers to both force latest IE rendering engine or Chrome Frame,
and also to cache the rendered page for 10 minutes. and also to cache the rendered page for 10 minutes.
""" """
if not 'Cache-Control' in response.headers: if 'Cache-Control' not in response.headers:
response.headers['Cache-Control'] = 'private, max-age=600, no-cache, must-revalidate' response.headers['Cache-Control'] = 'private, max-age=600, no-cache, must-revalidate'
return response return response
@app.context_processor @app.context_processor
def inject_version(): def inject_version():
return dict(karaoqueue_version=app.config['VERSION']) return dict(karaoqueue_version=app.config['VERSION'])

View File

@ -1,5 +1,5 @@
def dict_from_rows(rows): def dict_from_rows(rows):
outlist=[] outlist = []
for row in rows: for row in rows:
outlist.append(dict(row._mapping)) outlist.append(dict(row._mapping))
return outlist return outlist

View File

@ -1,11 +1,10 @@
# -*- coding: utf_8 -*- # -*- coding: utf_8 -*-
from email.mime import base
from MySQLdb import Connection
from sqlalchemy import create_engine, engine, text from sqlalchemy import create_engine, engine, text
import pandas import pandas
from io import StringIO from io import StringIO
from flask import current_app from flask import current_app
import uuid
song_table = "songs" song_table = "songs"
entry_table = "entries" entry_table = "entries"
@ -18,7 +17,6 @@ sql_engine = None
def get_db_engine() -> engine.base.Engine: def get_db_engine() -> engine.base.Engine:
global sql_engine global sql_engine
if (not sql_engine): if (not sql_engine):
print(current_app.config.get("DBCONNSTRING"))
sql_engine = create_engine( sql_engine = create_engine(
current_app.config.get("DBCONNSTRING")) # type: ignore current_app.config.get("DBCONNSTRING")) # type: ignore
return sql_engine return sql_engine
@ -75,7 +73,9 @@ def create_list_view():
stmt = text("""CREATE OR REPLACE VIEW `Liste` AS stmt = text("""CREATE OR REPLACE VIEW `Liste` AS
SELECT Name, Title, Artist, entries.Id AS entry_ID, songs.Id AS song_ID, entries.Transferred SELECT Name, Title, Artist, entries.Id AS entry_ID, songs.Id AS song_ID, entries.Transferred
FROM entries, songs FROM entries, songs
WHERE entries.Song_Id=songs.Id""") WHERE entries.Song_Id=songs.Id
ORDER BY entries.Id ASC
""")
conn.execute(stmt) conn.execute(stmt)
conn.commit() conn.commit()
@ -134,11 +134,11 @@ def get_song_completions(input_string):
def add_entry(name, song_id, client_id): def add_entry(name, song_id, client_id):
with get_db_engine().connect() as conn: with get_db_engine().connect() as conn:
stmt = text( stmt = text(
"INSERT INTO entries (Song_Id,Name,Client_Id) VALUES (:par_song_id,:par_name,:par_client_id);") "INSERT INTO entries (Song_Id,Name,Client_Id) VALUES (:par_song_id,:par_name,:par_client_id) RETURNING entries.ID;")
conn.execute(stmt, {"par_song_id": song_id, "par_name": name, cur = conn.execute(stmt, {"par_song_id": song_id, "par_name": name,
"par_client_id": client_id}) # type: ignore "par_client_id": client_id}) # type: ignore
conn.commit() conn.commit()
return True return cur.fetchone()[0] # type: ignore
def add_sung_song(entry_id): def add_sung_song(entry_id):
@ -189,6 +189,26 @@ def clear_played_songs():
return True return True
def get_entry(id):
try:
with get_db_engine().connect() as conn:
cur = conn.execute(text("SELECT * FROM Liste WHERE entry_ID = :par_id"),
{"par_id": id}) # type: ignore
return cur.fetchall()[0]
except Exception:
return None
def get_raw_entry(id):
try:
with get_db_engine().connect() as conn:
cur = conn.execute(text("SELECT * FROM entries WHERE ID = :par_id"),
{"par_id": id}) # type: ignore
return cur.fetchall()[0]
except Exception:
return None
def delete_entry(id): def delete_entry(id):
with get_db_engine().connect() as conn: with get_db_engine().connect() as conn:
conn.execute(text("DELETE FROM entries WHERE id= :par_id"), { conn.execute(text("DELETE FROM entries WHERE id= :par_id"), {
@ -207,7 +227,7 @@ def delete_entries(ids):
"par_id": idlist}) "par_id": idlist})
conn.commit() conn.commit()
return cur.rowcount return cur.rowcount
except Exception as error: except Exception:
return -1 return -1
@ -225,7 +245,7 @@ def get_config(key: str) -> str:
text("SELECT `Value` FROM config WHERE `Key`= :par_key"), {"par_key": key}) # type: ignore text("SELECT `Value` FROM config WHERE `Key`= :par_key"), {"par_key": key}) # type: ignore
conn.commit() conn.commit()
return cur.fetchall()[0][0] return cur.fetchall()[0][0]
except IndexError as error: except IndexError:
return "" return ""
@ -254,9 +274,24 @@ def check_config_table() -> bool:
if conn.dialect.has_table(conn, 'config'): if conn.dialect.has_table(conn, 'config'):
# type: ignore # type: ignore
# type: ignore # type: ignore
if (conn.execute(text("SELECT COUNT(*) FROM config")).fetchone()[0] > 0): # type: ignore if (conn.execute(text("SELECT COUNT(*) FROM config")).fetchone()[0] > 0): # type: ignore
return True return True
else: else:
return False return False
else: else:
return False return False
def init_event_id() -> bool:
if not get_config("EventID"):
set_config("EventID", str(uuid.uuid4()))
return True
def reset_event_id() -> bool:
set_config("EventID", str(uuid.uuid4()))
return True
def get_event_id() -> str:
return get_config("EventID")

View File

@ -1,6 +1,5 @@
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import json
import os import os
import uuid import uuid
from flask import make_response, Flask from flask import make_response, Flask
@ -8,6 +7,7 @@ from functools import wraps, update_wrapper
from datetime import datetime from datetime import datetime
import database import database
def get_catalog_url(): def get_catalog_url():
r = requests.get('https://www.karafun.de/karaoke-song-list.html') r = requests.get('https://www.karafun.de/karaoke-song-list.html')
soup = BeautifulSoup(r.content, 'html.parser') soup = BeautifulSoup(r.content, 'html.parser')
@ -69,13 +69,15 @@ def load_dbconfig(app: Flask):
else: else:
app.config['DBCONNSTRING'] = "" app.config['DBCONNSTRING'] = ""
else: else:
exit("No database connection string found. Cannot continue. Please set the environment variable DBSTRING or create a file .dbconn in the root directory of the project.") exit("""No database connection string found. Cannot continue.
Please set the environment variable DBSTRING or
create a file .dbconn in the root directory of the project.""")
# Check if config exists in DB, if not, create it. # Check if config exists in DB, if not, create it.
def setup_config(app: Flask): def setup_config(app: Flask):
if check_config_exists() == False: if check_config_exists() is False:
print("No config found, creating new config") print("No config found, creating new config")
initial_username = os.environ.get("INITIAL_USERNAME") initial_username = os.environ.get("INITIAL_USERNAME")
initial_password = os.environ.get("INITIAL_PASSWORD") initial_password = os.environ.get("INITIAL_PASSWORD")
@ -96,6 +98,7 @@ def setup_config(app: Flask):
for key, value in default_config.items(): for key, value in default_config.items():
database.set_config(key, value) database.set_config(key, value)
print("Created new config") print("Created new config")
database.init_event_id()
config = database.get_config_list() config = database.get_config_list()
app.config['BASIC_AUTH_USERNAME'] = config['username'] app.config['BASIC_AUTH_USERNAME'] = config['username']
app.config['BASIC_AUTH_PASSWORD'] = config['password'] app.config['BASIC_AUTH_PASSWORD'] = config['password']
@ -103,6 +106,7 @@ def setup_config(app: Flask):
app.config['MAX_QUEUE'] = config['maxqueue'] app.config['MAX_QUEUE'] = config['maxqueue']
app.config['ENTRIES_ALLOWED'] = bool(config['entries_allowed']) app.config['ENTRIES_ALLOWED'] = bool(config['entries_allowed'])
app.config['THEME'] = config['theme'] app.config['THEME'] = config['theme']
app.config['EVENT_ID'] = database.get_event_id()
# set queue admittance # set queue admittance
@ -151,6 +155,15 @@ def set_theme(app: Flask, theme: str):
print("Theme not found, not setting theme.") print("Theme not found, not setting theme.")
def get_current_event_id(app: Flask):
return app.config['EVENT_ID']
def reset_current_event_id(app: Flask):
database.reset_event_id()
app.config['EVENT_ID'] = database.get_event_id()
def nocache(view): def nocache(view):
@wraps(view) @wraps(view)
def no_cache(*args, **kwargs): def no_cache(*args, **kwargs):

View File

@ -1,4 +1,4 @@
from app import app from app import app
if __name__ == "__main__": if __name__ == "__main__":
app.run() app.run()

View File

@ -1,30 +1,36 @@
autopep8 autopep8==2.0.2
beautifulsoup4 beautifulsoup4==4.12.0
bs4 bs4==0.0.1
certifi certifi==2022.12.7
charset-normalizer charset-normalizer==3.1.0
click click==8.1.3
Flask flake8==6.0.0
Flask-BasicAuth Flask==2.2.3
greenlet Flask-BasicAuth==0.2.0
gunicorn greenlet==2.0.2
idna gunicorn==20.1.0
itsdangerous idna==3.4
Jinja2 itsdangerous==2.1.2
mariadb Jinja2==3.1.2
MarkupSafe mariadb==1.1.6
mysql MarkupSafe==2.1.2
mysqlclient mccabe==0.7.0
numpy mysql==0.0.3
pandas mysqlclient==2.1.1
pycodestyle numpy==1.24.2
PyMySQL packaging==23.0
python-dateutil pandas==1.5.3
pytz pycodestyle==2.10.0
requests pyflakes==3.0.1
six PyMySQL==1.0.3
soupsieve python-dateutil==2.8.2
SQLAlchemy pytz==2023.3
toml requests==2.28.2
urllib3 six==1.16.0
Werkzeug soupsieve==2.4
SQLAlchemy==2.0.7
toml==0.10.2
tomli==2.0.1
typing_extensions==4.5.0
urllib3==1.26.15
Werkzeug==2.2.3

View File

@ -1,4 +1,180 @@
:root { :root {
/* Navbar */
--navbar-background-color: #343a40;
--navbar-text-color: rgba(255, 255, 255, .5);
--navbar-text-color-hover: rgba(255, 255, 255, .75);
--navbar-text-color-active: rgba(255, 255, 255, 1);
/* Common */
--background-color: #ffffff;
--background-color-var: #f5f5f5;
--text-color: #212529;
--text-color-var: #343a40;
/* Modals */
--modal-background-color: #ffffff;
--modal-separator-color: #dee2e6;
--modal-close-color: #212529;
/* Tables */
--table-border-color: #dee2e6;
/* Input */
--input-background-color: #ffffff;
/* Misc */
--copy-highlight-color: rgba(251, 255, 0, 0.6);
}
body {
padding-top: 5rem;
background-color: var(--background-color);
}
html,
body {
height: 100%;
}
.site {
height: auto;
min-height: 100%;
}
main {
padding-bottom: 60px;
/* Höhe des Footers */
}
.footer {
margin-top: -60px;
width: 100%;
height: 60px;
/* Set the fixed height of the footer here */
/*line-height: 60px; /* Vertically center the text there */
background-color: var(--background-color-var);
}
.topbutton {
width: 100%;
}
table td {
overflow: hidden;
text-overflow: ellipsis;
}
table.entries tbody tr[data-index="0"] {
background-color: #007bff80;
font-weight: 600;
}
table.entries tbody tr[data-index="1"] {
background-color: #007bff40;
font-weight: 500;
}
table.entries tbody tr[data-index="2"] {
background-color: #007bff20;
font-weight: 400;
}
table.entries tbody tr[data-index="3"] {
background-color: #007bff10;
}
table td:first-child {
max-width: 200px !important;
}
.fa-solid {
vertical-align: auto;
}
@media (min-width: 768px) {
.topbutton {
width: auto;
}
}
@media print {
body {
font-size: 1.3em;
}
.footer {
display: none !important;
}
.admincontrols {
display: none;
}
}
body {
background-color: var(--background-color);
color: var(--text-color);
}
.footer {
background-color: var(--background-color-var);
}
.modal-content {
background-color: var(--background-color);
color: var(--text-color);
}
.modal-header {
background-color: var(--background-color);
color: var(--text-color-var);
border-color: var(var(--modal-separator-color));
}
.modal-footer {
background-color: var(--background-color);
color: var(--text-color-var);
border-color: var(var(--modal-separator-color));
}
.form-control {
background-color: var(--input-background-color);
color: var(--text-color)
}
.form-control:focus {
background-color: var(--input-background-color);
color: var(--text-color)
}
.table td,
.table th {
border-color: var(--table-border-color)
}
.table thead th {
border-color: var(--table-border-color)
}
table td.buttoncell {
text-align: end;
}
.close {
color: var(--text-color)
}
pre {
color: var(--text-color-var)
}
#entrytable td>span:hover {
background-color: var(--copy-highlight-color);
}
@media (prefers-color-scheme: dark) {
:root {
/* Navbar */ /* Navbar */
--navbar-background-color: #343a40; --navbar-background-color: #343a40;
--navbar-text-color: rgba(255, 255, 255, .5); --navbar-text-color: rgba(255, 255, 255, .5);
@ -6,189 +182,20 @@
--navbar-text-color-active: rgba(255, 255, 255, 1); --navbar-text-color-active: rgba(255, 255, 255, 1);
/* Common */ /* Common */
--background-color: #ffffff; --background-color: #121212;
--background-color-var: #f5f5f5; --background-color-var: #232323;
--text-color: #212529; --text-color: #f5f5f5;
--text-color-var: #343a40; --text-color-var: #a2a2a2;
/* Modals */ /* Modals */
--modal-background-color: #ffffff; --modal-background-color: #121212;
--modal-separator-color: #dee2e6; --modal-separator-color: #232323;
--modal-close-color: #212529; --modal-close-color: #f5f5f5;
/* Tables */ /* Tables */
--table-border-color: #dee2e6; --table-border-color: #232323;
/* Input */ /* Input */
--input-background-color: #ffffff; --input-background-color: #343434;
} }
body {
padding-top: 5rem;
background-color: var(--background-color);
}
html,
body {
height: 100%;
}
.site {
height: auto;
min-height: 100%;
}
main {
padding-bottom: 60px;
/* Höhe des Footers */
}
.footer {
margin-top: -60px;
width: 100%;
height: 60px;
/* Set the fixed height of the footer here */
/*line-height: 60px; /* Vertically center the text there */
background-color: var(--background-color-var);
}
.topbutton {
width: 100%;
}
table td {
overflow: hidden;
text-overflow: ellipsis;
}
table.entries tbody tr[data-index="0"] {
background-color: #007bff80;
font-weight: 600;
}
table.entries tbody tr[data-index="1"] {
background-color: #007bff40;
font-weight: 500;
}
table.entries tbody tr[data-index="2"] {
background-color: #007bff20;
font-weight: 400;
}
table.entries tbody tr[data-index="3"] {
background-color: #007bff10;
}
table td:first-child {
max-width: 200px !important;
}
.fa-solid {
vertical-align: auto;
}
@media (min-width: 768px) {
.topbutton {
width: auto;
}
}
@media print {
body {
font-size: 1.3em;
}
.footer {
display: none !important;
}
.admincontrols {
display: none;
}
}
body {
background-color: var(--background-color);
color: var(--text-color);
}
.footer {
background-color: var(--background-color-var);
}
.modal-content {
background-color: var(--background-color);
color: var(--text-color);
}
.modal-header {
background-color: var(--background-color);
color: var(--text-color-var);
border-color: var(var(--modal-separator-color));
}
.modal-footer {
background-color: var(--background-color);
color: var(--text-color-var);
border-color: var(var(--modal-separator-color));
}
.form-control {
background-color: var(--input-background-color);
color: var(--text-color)
}
.form-control:focus {
background-color: var(--input-background-color);
color: var(--text-color)
}
.table td,
.table th {
border-color: var(--table-border-color)
}
.table thead th {
border-color: var(--table-border-color)
}
table td.buttoncell {
text-align: end;
}
.close {
color: var(--text-color)
}
pre {
color: var(--text-color-var)
}
@media (prefers-color-scheme: dark) {
:root {
/* Navbar */
--navbar-background-color: #343a40;
--navbar-text-color: rgba(255, 255, 255, .5);
--navbar-text-color-hover: rgba(255, 255, 255, .75);
--navbar-text-color-active: rgba(255, 255, 255, 1);
/* Common */
--background-color: #121212;
--background-color-var: #232323;
--text-color: #f5f5f5;
--text-color-var: #a2a2a2;
/* Modals */
--modal-background-color: #121212;
--modal-separator-color: #232323;
--modal-close-color: #f5f5f5;
/* Tables */
--table-border-color: #232323;
/* Input */
--input-background-color: #343434;
}
} }

View File

@ -15,9 +15,12 @@
<!-- Bootstrap-Tables --> <!-- Bootstrap-Tables -->
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.21.2/dist/bootstrap-table.min.css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.21.2/dist/bootstrap-table.min.css">
<!-- Bootstrap-Toaster-->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-toaster/4.1.2/css/bootstrap-toaster.min.css"
integrity="sha512-kYPLvO+Bu+xttOhbQvxs9nx7XSdxrb2JexRxQ3CpJQ7EtmlkBsWyOjlinLgiLWeLxuupFYB4cPqLOo0gnBnzeQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Bootstrap core CSS --> <!-- Bootstrap core CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.2.1/dist/css/bootstrap.min.css" integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<!-- Custom styles for this template --> <!-- Custom styles for this template -->
<link href="static/css/style.css" rel="stylesheet"> <link href="static/css/style.css" rel="stylesheet">
@ -88,16 +91,12 @@
<!-- Bootstrap core JavaScript <!-- Bootstrap core JavaScript
================================================== --> ================================================== -->
<!-- Placed at the end of the document so the pages load faster --> <!-- Placed at the end of the document so the pages load faster -->
<script src="https://code.jquery.com/jquery-3.2.1.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.4/jquery.min.js"
integrity="sha384-xBuQ/xzmlsLoJpyjoggmTEz8OWUFM0/RC5BsqQBDX2v5cMvDHcMakNTNrHIW2I5f" crossorigin="anonymous"> integrity="sha512-pumBsjNRGGqkPzKHndZMaAG+bir374sORyzM3uulLV14lN5LyykqNk8eEeUlUkB3U0M4FApyaHraT65ihJhDpQ=="
</script> crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" <script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.6/dist/umd/popper.min.js" integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut" crossorigin="anonymous"></script>
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"> <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.2.1/dist/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>
</script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"
integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous">
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootbox.js/4.4.0/bootbox.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/bootbox.js/4.4.0/bootbox.min.js"
integrity="sha256-4F7e4JsAJyLUdpP7Q8Sah866jCOhv72zU5E8lIRER4w=" crossorigin="anonymous"> integrity="sha256-4F7e4JsAJyLUdpP7Q8Sah866jCOhv72zU5E8lIRER4w=" crossorigin="anonymous">
</script> </script>
@ -105,10 +104,13 @@
<script <script
src="https://unpkg.com/bootstrap-table@1.21.2/dist/extensions/auto-refresh/bootstrap-table-auto-refresh.min.js"></script> src="https://unpkg.com/bootstrap-table@1.21.2/dist/extensions/auto-refresh/bootstrap-table-auto-refresh.min.js"></script>
<script src="https://gitcdn.github.io/bootstrap-toggle/2.2.2/js/bootstrap-toggle.min.js"></script> <script src="https://gitcdn.github.io/bootstrap-toggle/2.2.2/js/bootstrap-toggle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-toaster/4.1.2/js/bootstrap-toaster.min.js"
integrity="sha512-Ur6jgeoP3jnn38C7oBzDqMLRb+wxG2PXLKqgx2vgQ1ePFvbJ28f9iQSJplHD0APFHELOeS/df+RPNeENFtLrYw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
{% block extrajs %}{% endblock %} {% block extrajs %}{% endblock %}
<script> <script>
$(document).ready(function () { $(document).ready(function () {
loadOrGenerateClientId() checkEventID()
// get current URL path and assign 'active' class // get current URL path and assign 'active' class
var pathname = window.location.pathname; var pathname = window.location.pathname;
$('.navbar-nav > li > a[href="' + pathname + '"]').parent().addClass('active'); $('.navbar-nav > li > a[href="' + pathname + '"]').parent().addClass('active');
@ -130,6 +132,50 @@
localStorage.setItem("clientId", create_UUID()) localStorage.setItem("clientId", create_UUID())
} }
} }
function getClientId() {
return localStorage.getItem("clientId")
}
async function checkEventID() {
const localEventID = localStorage.getItem("eventID")
const resp = await fetch("/api/events/current")
const respJson = await resp.json()
const remoteEventID = respJson.event
if (localEventID == null || localEventID != remoteEventID) {
localStorage.clear()
localStorage.setItem("eventID", remoteEventID)
loadOrGenerateClientId()
}
}
function addEntry(entryId) {
entryArray = JSON.parse(localStorage.getItem("ownedEntries"))
if (entryArray == null) {
entryArray = []
}
entryArray.push(entryId)
localStorage.setItem("ownedEntries", JSON.stringify(entryArray))
}
function removeEntry(entryId) {
entryArray = JSON.parse(localStorage.getItem("ownedEntries"))
if (entryArray == null) {
entryArray = []
}
entryArray = entryArray.filter(function(value, index, arr){ return value != entryId;});
localStorage.setItem("ownedEntries", JSON.stringify(entryArray))
}
function getOwnedEntries() {
var entries = JSON.parse(localStorage.getItem("ownedEntries"))
if (entries == null) {
entries = []
}
return entries;
}
</script> </script>
</body> </body>

View File

@ -17,6 +17,7 @@
<th data-field="Name">Name</th> <th data-field="Name">Name</th>
<th data-field="Title">Song</th> <th data-field="Title">Song</th>
<th data-field="Artist">Künstler</th> <th data-field="Artist">Künstler</th>
<th scope="col" data-formatter="TableActionsFormatter"></th>
</tr> </tr>
</thead> </thead>
</table> </table>
@ -34,5 +35,43 @@ $.getJSON("/api/entries/accept", (data) => {
$('[data-toggle="tooltip"]').tooltip() $('[data-toggle="tooltip"]').tooltip()
} }
}) })
function TableActionsFormatter(value,row,index) {
console.log("Value: " + value + ", Row: " + row + ", Index: " + index)
console.log(row)
if (getOwnedEntries().includes(row.entry_ID)) {
return "<button type='button' class='btn btn-danger' data-toggle='tooltip' data-placement='top' title='Eintrag zurückziehen' onclick=\"event.stopPropagation();$(this).tooltip('dispose');requestDeletionAsUser("+row["entry_ID"]+")\"><i class='fas fa-trash'></i></button>"
}
return ""
}
function requestDeletionAsUser(id) {
bootbox.confirm("Wirklich den Eintrag zurückziehen? Das könnte zu einer langen Wartezeit führen!", function (result) {
if (result) {
payload = {
"client_id": localStorage.getItem("clientId"),
"entry_id": id
}
$.ajax({
url: "/api/entries/delete/"+id,
type: "POST",
data: JSON.stringify(payload),
contentType: "application/json; charset=utf-8",
dataType: "json",
success: function(result) {
toast = {
title: "Erfolgreich zurückgezogen",
message: "Eintrag wurde gelöscht",
status: TOAST_STATUS.SUCCESS,
timeout: 5000
}
Toast.create(toast);
location.reload()
}
})
}
})
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -26,9 +26,9 @@
<thead> <thead>
<tr> <tr>
<th data-field="state" data-checkbox="true"></th> <th data-field="state" data-checkbox="true"></th>
<th scope="col" data-field="Name">Name</th> <th scope="col" data-field="Name" data-formatter="CopyFormatter">Name</th>
<th scope="col" data-field="Title">Song</th> <th scope="col" data-field="Title"data-formatter="CopyFormatter">Song</th>
<th scope="col" data-field="Artist">Künstler</th> <th scope="col" data-field="Artist"data-formatter="CopyFormatter">Künstler</th>
<th scope="col" data-formatter="TableActions">Aktionen</th> <th scope="col" data-formatter="TableActions">Aktionen</th>
</tr> </tr>
</thead> </thead>
@ -185,8 +185,6 @@
}); });
} }
function TableActions(value, row, index) { function TableActions(value, row, index) {
console.log("Value: " + value + ", Row: " + row + ", Index: " + index)
console.log(row)
let outerHTML = "" let outerHTML = ""
if (row.Transferred == 1) { if (row.Transferred == 1) {
outerHTML = "<button type=\"button\" class=\"btn btn-default\" data-toggle=\"tooltip\" data-placement=\"top\" title=\"Markierung zurückziehen\" onclick=\"event.stopPropagation();$(this).tooltip('dispose');markEntryAsTransferred(" + row.entry_ID + ")\"><i class=\"fas fa-backward\"></i></button>&nbsp;<button type=\"button\" class=\"btn btn-success\" data-toggle=\"tooltip\" data-placement=\"top\" title=\"Als gesungen markieren\" onclick=\"event.stopPropagation();$(this).tooltip('dispose');markEntryAsSung(" + row.entry_ID + ")\"><i class=\"fas fa-check\"></i></button>&nbsp;<button type=\"button\" class=\"btn btn-danger\" data-toggle=\"tooltip\" data-placement=\"top\" title=\"Eintrag löschen\" onclick=\"event.stopPropagation();$(this).tooltip('dispose');confirmDeleteEntry('" + row.Name + "'," + row.entry_ID + ")\"><i class=\"fas fa-trash\"></i></button>"; outerHTML = "<button type=\"button\" class=\"btn btn-default\" data-toggle=\"tooltip\" data-placement=\"top\" title=\"Markierung zurückziehen\" onclick=\"event.stopPropagation();$(this).tooltip('dispose');markEntryAsTransferred(" + row.entry_ID + ")\"><i class=\"fas fa-backward\"></i></button>&nbsp;<button type=\"button\" class=\"btn btn-success\" data-toggle=\"tooltip\" data-placement=\"top\" title=\"Als gesungen markieren\" onclick=\"event.stopPropagation();$(this).tooltip('dispose');markEntryAsSung(" + row.entry_ID + ")\"><i class=\"fas fa-check\"></i></button>&nbsp;<button type=\"button\" class=\"btn btn-danger\" data-toggle=\"tooltip\" data-placement=\"top\" title=\"Eintrag löschen\" onclick=\"event.stopPropagation();$(this).tooltip('dispose');confirmDeleteEntry('" + row.Name + "'," + row.entry_ID + ")\"><i class=\"fas fa-trash\"></i></button>";
@ -196,10 +194,26 @@
return outerHTML; return outerHTML;
} }
function CopyFormatter(value, row, index) {
return "<span onclick='copyAndNotify(\""+value+"\")'>"+value+"</span>";
}
function getIdSelections() { function getIdSelections() {
return $.map($("#entrytable").bootstrapTable('getSelections'), function (row) { return $.map($("#entrytable").bootstrapTable('getSelections'), function (row) {
return row.entry_ID return row.entry_ID
}) })
} }
function copyAndNotify(text) {
navigator.clipboard.writeText(text).then(function () {
let toast = {
title: "Kopiert",
message: text,
status: 1,
timeout: 5000
};
Toast.create(toast);
})
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -79,7 +79,7 @@
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: '/api/enqueue', url: '/api/enqueue',
data: JSON.stringify(data), // or JSON.stringify ({name: 'jonas'}), data: JSON.stringify(data),
success: success_callback, success: success_callback,
statusCode: { statusCode: {
423: blocked_callback 423: blocked_callback
@ -96,7 +96,18 @@
function submitModal() { function submitModal() {
var name = $("#singerNameInput").val(); var name = $("#singerNameInput").val();
var id = $("#selectedId").attr("value"); var id = $("#selectedId").attr("value");
enqueue(localStorage.getItem("clientId"),id, name, function () { enqueue(localStorage.getItem("clientId"),id, name, function (response) {
console.log(response);
entryID = response["entry_id"];
toast = {
title: "Erfolgreich eingetragen",
message: "Du wurdest erfolgreich eingetragen.",
status: TOAST_STATUS.SUCCESS,
timeout: 5000
}
Toast.create(toast);
console.log("Entry ID: " + entryID);
addEntry(entryID);
$("#enqueueModal").modal('hide'); $("#enqueueModal").modal('hide');
window.location.href = '/#end'; window.location.href = '/#end';
}, function (response) { }, function (response) {

View File

@ -6,7 +6,7 @@ secrets:
services: services:
karaoqueue: karaoqueue:
image: "ghcr.io/phoenixtwofive/karaoqueue:v2023.03.1" image: "ghcr.io/phoenixtwofive/karaoqueue:v2023.04"
build: . build: .
restart: always restart: always
ports: ports: