Compare commits

..

59 Commits

Author SHA1 Message Date
4fd58bc39f Fix button alignment 2023-03-30 22:28:12 +02:00
bf97e9e5e4 Fix container version 2023-03-30 22:21:42 +02:00
e08041338a Add persistent volume to mariadb container. 2023-03-30 21:18:40 +02:00
4c64144f3d Fix sticky Popups 2023-03-30 21:05:36 +02:00
fad21ba6c5 Fix DELETE statements 2023-03-30 19:30:27 +02:00
3f09b79844 Fix Dockerfile for "new" WSGI server 2023-03-30 19:30:13 +02:00
1bfe3b5d4b Update Dockerfile 2023-03-30 19:14:24 +02:00
f055a59a38 Fix Dockerfile with in-Container update 2023-03-30 18:14:40 +02:00
dc53d8a8b1 Fix Typos 2023-03-30 18:00:23 +02:00
fe71fa2d8c Fix Caching 2023-03-30 18:00:08 +02:00
7ef938a5ff Create new config with credentials from env vars 2023-03-30 17:51:20 +02:00
12207c1246 Admin credentials can be changed via settings 2023-03-30 17:39:44 +02:00
16cb9e7d5a Fix theme setting persistence 2023-03-30 17:39:13 +02:00
429ffddced Update data_adapters for sqlalchemy upgrade 2023-03-30 17:38:34 +02:00
ca73d57567 Update Requirements 2023-03-30 17:07:01 +02:00
cf6d586856 Overhaul database code 2023-03-30 17:06:52 +02:00
24458a78d0 Fix bugs 2023-03-30 17:06:36 +02:00
a2cf4fc47f Change .env.dev to reflect new settings storage 2023-03-30 17:05:46 +02:00
e84ff1a381 Change container registry to github 2023-03-30 17:05:17 +02:00
79156c539a Better Dark Mode 2023-03-30 04:43:53 +02:00
757bfa2483 Fix Dark Mode 2023-03-30 03:28:23 +02:00
d56eb609b9 Fix entry button alignment 2023-03-30 03:25:02 +02:00
6d9541f0bd Add Dark Mode 2023-03-30 03:24:45 +02:00
ee4774159c Update Python to 3.10 2023-03-30 03:23:22 +02:00
6d084ee83c Add theming 2023-03-30 02:57:21 +02:00
971548189f Remove redundant save button from settings 2023-03-30 02:26:24 +02:00
da8ad57293 Fix Settings 2023-03-30 02:22:59 +02:00
98981b1e1e Move config to database 2023-03-30 02:22:46 +02:00
d33006251e Remove inline env from docker-compose.prod.yml 2023-03-30 02:22:02 +02:00
2a8040642a Merge branch 'legacy' into feature/legacy/mariadb_database 2023-03-28 01:26:24 +02:00
cbf7aad8ac Fix Type errors 2023-03-28 01:25:33 +02:00
d5a21b82de Add GitHub Link to footer 2023-03-28 01:00:41 +02:00
551536bcb4 Update bootstrap-tables 2023-03-28 01:00:21 +02:00
b8220732ee Introduce "dev environment" and dotenv
There are now two dockerfiles. One for production, one for development.
All configuration is now also handled through dotenv files,
which these dotenv files, as well as the included VSCode launch tasks
use.
2023-03-28 00:54:04 +02:00
831166f38b Intall dependencies using requirements.txt 2023-03-27 23:38:22 +02:00
41a24ad9ce readd main.py 2023-03-27 01:02:26 +02:00
6efb234a1c Merge pull request #39 from PhoenixTwoFive/feature/legacy/mariadb_database
Feature/legacy/mariadb database
2022-10-08 19:51:56 +02:00
698c3717fd Remove outdated workflows 2022-10-08 19:50:35 +02:00
8dd90b728c MySQL Working, bug fixes. 2022-10-08 19:47:29 +02:00
a22960eae9 Fix DB Connection 2022-07-15 15:54:15 +02:00
bc44985fed Fix DBCONNSTRING 2022-07-15 15:35:58 +02:00
72914109c0 prune requirements 2022-07-15 15:33:42 +02:00
57ced69ddd Load DB Connection from ENV 2022-07-15 15:27:36 +02:00
fc78bdc4fe Fix types 2022-07-15 15:09:48 +02:00
adfd120636 Update package names for 22.04 2022-07-15 15:01:07 +02:00
67a9552fee Update ubuntu to get newer mariadb connector 2022-07-15 14:55:14 +02:00
3ae2803fcc Remove mysql and install mariadb 2022-07-15 14:51:05 +02:00
2138189dff python3 is still not default on Ubuntu? 2022-07-15 14:44:26 +02:00
9907a19066 Test 2022-07-15 14:43:16 +02:00
2a18f3dc2c Completely remove mariadb libs to hopefully fix conflicts 2022-07-15 14:36:47 +02:00
69e252e28a Add missing libraries to install 2022-07-15 14:35:52 +02:00
8151e615c0 Test 2022-07-15 14:34:07 +02:00
2a92c39450 Test 2022-07-15 14:32:51 +02:00
b88aac69e6 Fix.. 2022-07-15 14:32:06 +02:00
c9ad755a04 Fix? 2022-07-15 14:29:18 +02:00
3527ba4fd9 Fix dependencies 2022-07-15 14:27:39 +02:00
fb9677dc88 Fix github workflow 2022-07-15 14:23:17 +02:00
97dd80b03a Add or update the Azure App Service build and deployment workflow config 2022-07-15 14:16:06 +02:00
faad60346f Intial changes for MariaDB 2022-07-15 14:01:02 +02:00
57 changed files with 756 additions and 13874 deletions

12
.env.dev Normal file
View File

@ -0,0 +1,12 @@
# MariaDB
MARIADB_ROOT_PASSWORD=mariadb_root_password
MARIADB_ROOT_HOST=localhost
MARIADB_DATABASE=karaoqueue
MARIADB_USER=karaoqueue
MARIADB_PASSWORD=mariadb_karaoqueue_password
# Karaoqueue
DEPLOYMENT_PLATFORM=Docker
DBSTRING="mysql+pymysql://karaoqueue:mariadb_karaoqueue_password@127.0.0.1:3306/karaoqueue?charset=utf8mb4"
INITIAL_USERNAME=admin
INITIAL_PASSWORD=changeme

5
.gitignore vendored
View File

@ -137,4 +137,7 @@ data/
node_modules/ node_modules/
# Version identification file # Version identification file
.version .version
# Docker secrets
secrets.yml

14
.vscode/launch.json vendored
View File

@ -5,7 +5,7 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"preLaunchTask": "versiondump", "preLaunchTask": "mariadb",
"name": "Python: Flask", "name": "Python: Flask",
"type": "python", "type": "python",
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
@ -14,8 +14,9 @@
"env": { "env": {
"FLASK_APP": "backend/app.py", "FLASK_APP": "backend/app.py",
"FLASK_ENV": "development", "FLASK_ENV": "development",
"FLASK_DEBUG": "1" "FLASK_DEBUG": "1",
}, },
"envFile": "${workspaceFolder}/.env.dev",
"args": [ "args": [
"run", "run",
"--no-debugger", "--no-debugger",
@ -24,7 +25,7 @@
"jinja": true "jinja": true
}, },
{ {
"preLaunchTask": "versiondump", "preLaunchTask": "mariadb",
"name": "Python: Flask (with reload)", "name": "Python: Flask (with reload)",
"type": "python", "type": "python",
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
@ -35,6 +36,7 @@
"FLASK_ENV": "development", "FLASK_ENV": "development",
"FLASK_DEBUG": "1" "FLASK_DEBUG": "1"
}, },
"envFile": "${workspaceFolder}/.env.dev",
"args": [ "args": [
"run", "run",
"--no-debugger" "--no-debugger"
@ -42,7 +44,7 @@
"jinja": true "jinja": true
}, },
{ {
"preLaunchTask": "versiondump", "preLaunchTask": "mariadb",
"name": "Python: Flask (with reload, externally reachable)", "name": "Python: Flask (with reload, externally reachable)",
"type": "python", "type": "python",
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
@ -53,6 +55,7 @@
"FLASK_ENV": "development", "FLASK_ENV": "development",
"FLASK_DEBUG": "1" "FLASK_DEBUG": "1"
}, },
"envFile": "${workspaceFolder}/.env.dev",
"args": [ "args": [
"run", "run",
"--no-debugger", "--no-debugger",
@ -61,7 +64,7 @@
"jinja": true "jinja": true
}, },
{ {
"preLaunchTask": "versiondump", "preLaunchTask": "mariadb",
"name": "Python: Flask (externally reachable)", "name": "Python: Flask (externally reachable)",
"type": "python", "type": "python",
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
@ -72,6 +75,7 @@
"FLASK_ENV": "development", "FLASK_ENV": "development",
"FLASK_DEBUG": "1" "FLASK_DEBUG": "1"
}, },
"envFile": "${workspaceFolder}/.env.dev",
"args": [ "args": [
"run", "run",
"--no-debugger", "--no-debugger",

View File

@ -1,9 +1,3 @@
{ {
"python.pythonPath": "/usr/bin/python", "python.pythonPath": "/usr/bin/python"
"files.exclude": {
"**/.classpath": true,
"**/.project": true,
"**/.settings": true,
"**/.factorypath": true
}
} }

14
.vscode/tasks.json vendored
View File

@ -1,5 +1,5 @@
{ {
// See https://go.microsoft.com/fwlink/?LinkId=733558 // See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format // for the documentation about the tasks.json format
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
@ -10,13 +10,11 @@
"problemMatcher": [] "problemMatcher": []
}, },
{ {
"label": "Start Frontend", "label": "mariadb",
"type": "npm", "type": "shell",
"script": "start", "command": "docker-compose -f docker-compose.yml up --remove-orphans",
"path": "frontend/ng-karaoqueue/", "isBackground": true,
"problemMatcher": [ "activeOnStart": false
"$tsc"
]
} }
] ]
} }

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM tiangolo/meinheld-gunicorn-flask:python3.9
RUN apt-key adv --recv-keys --keyserver keyserver.ubuntu.com 0xcbcb082a1bb943db
RUN curl -LsS https://downloads.mariadb.com/MariaDB/mariadb_repo_setup | bash
RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get dist-upgrade
COPY ./backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
RUN pip install --no-cache-dir -U meinheld
COPY ./backend /app

View File

@ -1,13 +0,0 @@
{
"folders": [
{
"path": "./frontend/ng-karaoqueue"
},
{
"path": "backend"
},
{
"path": "docs"
}
]
}

View File

@ -1,4 +1,5 @@
from flask import Flask, render_template, Response, abort, request, redirect, send_from_directory from flask import Flask, render_template, abort, request, redirect, send_from_directory, jsonify
from flask.wrappers import Request, Response
import helpers import helpers
import database import database
import data_adapters import data_adapters
@ -9,7 +10,7 @@ from helpers import nocache
app = Flask(__name__, static_url_path='/static') app = Flask(__name__, static_url_path='/static')
basic_auth = BasicAuth(app) basic_auth = BasicAuth(app)
accept_entries = False accept_entries = True
@app.route("/") @app.route("/")
def home(): def home():
@ -41,7 +42,7 @@ def enqueue():
database.add_entry(name, song_id, client_id) database.add_entry(name, song_id, client_id)
return Response('{"status":"OK"}', mimetype='text/json') return Response('{"status":"OK"}', mimetype='text/json')
else: else:
if accept_entries: if helpers.get_accept_entries(app):
if not request.json: if not request.json:
print(request.data) print(request.data)
abort(400) abort(400)
@ -51,8 +52,8 @@ def enqueue():
abort(400) abort(400)
name = request.json['name'] name = request.json['name']
song_id = request.json['id'] song_id = request.json['id']
if database.check_queue_length() < app.config['MAX_QUEUE']: if database.check_queue_length() < int(app.config['MAX_QUEUE']):
if database.check_entry_quota(client_id) < app.config['ENTRY_QUOTA']: if database.check_entry_quota(client_id) < int(app.config['ENTRY_QUOTA']):
database.add_entry(name, song_id, client_id) database.add_entry(name, song_id, client_id)
return Response('{"status":"OK"}', mimetype='text/json') return Response('{"status":"OK"}', mimetype='text/json')
else: else:
@ -72,7 +73,7 @@ def songlist():
@nocache @nocache
@basic_auth.required @basic_auth.required
def settings(): def settings():
return render_template('settings.html', app=app, auth=basic_auth.authenticate()) return render_template('settings.html', app=app, auth=basic_auth.authenticate(), themes=helpers.get_themes())
@app.route("/settings", methods=['POST']) @app.route("/settings", methods=['POST'])
@ -81,16 +82,33 @@ def settings():
def settings_post(): def settings_post():
entryquota = request.form.get("entryquota") entryquota = request.form.get("entryquota")
maxqueue = request.form.get("maxqueue") maxqueue = request.form.get("maxqueue")
if entryquota.isnumeric() and int(entryquota) > 0: theme = request.form.get("theme")
app.config['ENTRY_QUOTA'] = int(entryquota) username = request.form.get("username")
password = request.form.get("password")
changed_credentials = False
if entryquota.isnumeric() and int(entryquota) > 0: # type: ignore
app.config['ENTRY_QUOTA'] = int(entryquota) # type: ignore
else: else:
abort(400) abort(400)
if maxqueue.isnumeric and int(maxqueue) > 0: if maxqueue.isnumeric and int(maxqueue) > 0: # type: ignore
app.config['MAX_QUEUE'] = int(maxqueue) app.config['MAX_QUEUE'] = int(maxqueue) # type: ignore
else: else:
abort(400) abort(400)
if theme is not None and theme in helpers.get_themes():
return render_template('settings.html', app=app, auth=basic_auth.authenticate()) helpers.set_theme(app,theme)
else:
abort(400)
if username != "" and username != app.config['BASIC_AUTH_USERNAME']:
app.config['BASIC_AUTH_USERNAME'] = username
changed_credentials = True
if password != "":
app.config['BASIC_AUTH_PASSWORD'] = password
changed_credentials = True
helpers.persist_config(app=app)
if changed_credentials:
return redirect("/")
else:
return render_template('settings.html', app=app, auth=basic_auth.authenticate(), themes=helpers.get_themes())
@app.route("/api/queue") @app.route("/api/queue")
@ -125,14 +143,14 @@ def update_songs():
return Response('{"status": "%s" }' % status, mimetype='text/json') return Response('{"status": "%s" }' % status, mimetype='text/json')
@app.route("/api/songs/compl") @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) print(input_string)
list = database.get_song_completions(input_string=input_string) result = [list(x) for x in database.get_song_completions(input_string=input_string)]
return Response(json.dumps(list, ensure_ascii=False).encode('utf-8'), mimetype='text/json') return jsonify(result)
else: else:
return 400 return 400
@ -186,9 +204,8 @@ def mark_transferred(entry_id):
@nocache @nocache
@basic_auth.required @basic_auth.required
def set_accept_entries(value): def set_accept_entries(value):
global accept_entries
if (value == '0' or value == '1'): if (value == '0' or value == '1'):
accept_entries = 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)
@ -197,7 +214,7 @@ def set_accept_entries(value):
@app.route("/api/entries/accept") @app.route("/api/entries/accept")
@nocache @nocache
def get_accept_entries(): def get_accept_entries():
global accept_entries 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')
@ -229,6 +246,7 @@ def admin():
@app.before_first_request @app.before_first_request
def activate_job(): def activate_job():
helpers.load_dbconfig(app)
helpers.load_version(app) helpers.load_version(app)
helpers.create_data_directory() helpers.create_data_directory()
database.create_entry_table() database.create_entry_table()
@ -236,6 +254,7 @@ def activate_job():
database.create_done_song_table() database.create_done_song_table()
database.create_list_view() database.create_list_view()
database.create_done_song_view() database.create_done_song_view()
database.create_config_table()
helpers.setup_config(app) helpers.setup_config(app)
@ -247,7 +266,7 @@ def add_header(response):
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 not 'Cache-Control' in response.headers:
response.headers['Cache-Control'] = 'private, max-age=600' response.headers['Cache-Control'] = 'private, max-age=600, no-cache, must-revalidate'
return response return response
@app.context_processor @app.context_processor

View File

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

View File

@ -1,190 +1,199 @@
# -*- coding: utf_8 -*- # -*- coding: utf_8 -*-
import sqlite3 from email.mime import base
from MySQLdb import Connection
from sqlalchemy import create_engine, engine, text
import pandas import pandas
from io import StringIO from io import StringIO
from flask import current_app
song_table = "songs" song_table = "songs"
entry_table = "entries" entry_table = "entries"
index_label = "Id" index_label = "Id"
done_table = "done_songs" done_table = "done_songs"
sql_engine = None
def open_db():
conn = sqlite3.connect("/tmp/karaoqueue.db") def get_db_engine() -> engine.base.Engine:
conn.execute('PRAGMA encoding = "UTF-8";') global sql_engine
return conn if (not sql_engine):
print(current_app.config.get("DBCONNSTRING"))
sql_engine = create_engine(
current_app.config.get("DBCONNSTRING")) # type: ignore
return sql_engine
def import_songs(song_csv): def import_songs(song_csv):
print("Start importing Songs...") print("Start importing Songs...")
df = pandas.read_csv(StringIO(song_csv), sep=';') df = pandas.read_csv(StringIO(song_csv), sep=';')
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() df.to_sql(song_table, conn, if_exists='replace',
df.to_sql(song_table, conn, if_exists='replace', index=False)
index=False) cur = conn.execute(text("SELECT Count(Id) FROM songs"))
cur.execute("SELECT Count(Id) FROM songs") num_songs = cur.fetchone()[0] # type: ignore
num_songs = cur.fetchone()[0] conn.commit()
conn.close()
print("Imported songs ({} in Database)".format(num_songs)) print("Imported songs ({} in Database)".format(num_songs))
return("Imported songs ({} in Database)".format(num_songs)) return ("Imported songs ({} in Database)".format(num_songs))
def create_entry_table(): def create_entry_table():
conn = open_db() with get_db_engine().connect() as conn:
conn.execute('CREATE TABLE IF NOT EXISTS '+entry_table + stmt = text(
' (ID INTEGER PRIMARY KEY NOT NULL, Song_Id INTEGER NOT NULL, Name VARCHAR(255), Client_Id VARCHAR(36), Transferred INTEGER DEFAULT 0)') f'CREATE TABLE IF NOT EXISTS `{entry_table}` (ID INTEGER PRIMARY KEY NOT NULL AUTO_INCREMENT, Song_Id INTEGER NOT NULL, Name VARCHAR(255), Client_Id VARCHAR(36), Transferred INTEGER DEFAULT 0)')
conn.close() conn.execute(stmt)
conn.commit()
def create_done_song_table(): def create_done_song_table():
conn = open_db() with get_db_engine().connect() as conn:
conn.execute('CREATE TABLE IF NOT EXISTS '+done_table + stmt = text(
' (Song_Id INTEGER PRIMARY KEY NOT NULL, Plays INTEGER)') f'CREATE TABLE IF NOT EXISTS `{done_table}` (Song_Id INTEGER PRIMARY KEY NOT NULL, Plays INTEGER)')
conn.close() conn.execute(stmt)
conn.commit()
def create_song_table(): def create_song_table():
conn = open_db() with get_db_engine().connect() as conn:
conn.execute("CREATE TABLE IF NOT EXISTS \""+song_table+"""\" ( stmt = text(f"""CREATE TABLE IF NOT EXISTS `{song_table}` (
"Id" INTEGER, `Id` INTEGER,
"Title" TEXT, `Title` TEXT,
"Artist" TEXT, `Artist` TEXT,
"Year" INTEGER, `Year` VARCHAR(4),
"Duo" INTEGER, `Duo` BOOLEAN,
"Explicit" INTEGER, `Explicit` INTEGER,
"Date Added" TEXT, `Date Added` TIMESTAMP,
"Styles" TEXT, `Styles` TEXT,
"Languages" TEXT `Languages` TEXT
)""") )""")
conn.close() conn.execute(stmt)
conn.commit()
def create_list_view(): def create_list_view():
conn = open_db() with get_db_engine().connect() as conn:
conn.execute("""CREATE VIEW IF NOT EXISTS [Liste] AS stmt = text("""CREATE OR REPLACE VIEW `Liste` AS
SELECT Name, Title, Artist, entries.Id, songs.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""")
conn.close() conn.execute(stmt)
conn.commit()
def create_done_song_view(): def create_done_song_view():
conn = open_db() with get_db_engine().connect() as conn:
conn.execute("""CREATE VIEW IF NOT EXISTS [Abspielliste] AS stmt = text("""CREATE OR REPLACE VIEW `Abspielliste` AS
SELECT Artist || \" - \" || Title AS Song, Plays AS Wiedergaben SELECT CONCAT(Artist," - ", Title) AS Song, Plays AS Wiedergaben
FROM songs, done_songs FROM songs, done_songs
WHERE done_songs.Song_Id=songs.Id""") WHERE done_songs.Song_Id=songs.Id""")
conn.close() conn.execute(stmt)
conn.commit()
def create_config_table():
with get_db_engine().connect() as conn:
stmt = text("""CREATE TABLE IF NOT EXISTS `config` (
`Key` VARCHAR(50) NOT NULL PRIMARY KEY,
`Value` TEXT
)""")
conn.execute(stmt)
conn.commit()
def get_list(): def get_list():
conn = open_db() with get_db_engine().connect() as conn:
conn.row_factory = sqlite3.Row stmt = text("SELECT * FROM Liste")
cur = conn.cursor() cur = conn.execute(stmt)
cur.execute("SELECT * FROM Liste")
return cur.fetchall() return cur.fetchall()
def get_played_list(): def get_played_list():
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() stmt = text("SELECT * FROM Abspielliste")
cur.execute("SELECT * FROM Abspielliste") cur = conn.execute(stmt)
return cur.fetchall() return cur.fetchall()
def get_song_list(): def get_song_list():
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() stmt = text("SELECT Artist || \" - \" || Title AS Song, Id FROM songs;")
cur.execute("SELECT Artist || \" - \" || Title AS Song, Id FROM songs;") cur = conn.execute(stmt)
return cur.fetchall() return cur.fetchall()
def get_song_completions(input_string): def get_song_completions(input_string):
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() prepared_string = f"%{input_string.upper()}%"
# Don't look, it burns... stmt = text(
prepared_string = "%{0}%".format( "SELECT CONCAT(Artist, ' - ', Title) AS Song, Id FROM songs WHERE CONCAT(Artist, ' - ', Title) LIKE :prepared_string LIMIT 20;")
input_string).upper() # "Test" -> "%TEST%" cur = conn.execute(
print(prepared_string) stmt, {"prepared_string": prepared_string}) # type: ignore
cur.execute( return cur.fetchall()
"SELECT Title || \" - \" || Artist AS Song, Id FROM songs WHERE REPLACE(REPLACE(REPLACE(REPLACE(UPPER( SONG ),'ö','Ö'),'ü','Ü'),'ä','Ä'),'ß','') LIKE (?) LIMIT 20;", (prepared_string,))
return cur.fetchall()
def add_entry(name, song_id, client_id): def add_entry(name, song_id, client_id):
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() stmt = text(
cur.execute( "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(?,?,?);", (song_id, name, client_id)) conn.execute(stmt, {"par_song_id": song_id, "par_name": name,
conn.commit() "par_client_id": client_id}) # type: ignore
conn.close() conn.commit()
return return True
def add_sung_song(entry_id): def add_sung_song(entry_id):
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() stmt = text("SELECT Song_Id FROM entries WHERE Id=:par_entry_id")
cur.execute("""SELECT Song_Id FROM entries WHERE Id=?""", (entry_id,)) cur = conn.execute(stmt, {"par_entry_id": entry_id}) # type: ignore
song_id = cur.fetchone()[0] song_id = cur.fetchone()[0] # type: ignore
cur.execute("""INSERT OR REPLACE INTO done_songs (Song_Id, Plays) stmt = text(
VALUES("""+str(song_id)+""", "INSERT INTO done_songs (Song_Id,Plays) VALUES (:par_song_id,1) ON DUPLICATE KEY UPDATE Plays=Plays + 1;")
COALESCE( conn.execute(stmt, {"par_song_id": song_id}) # type: ignore
(SELECT Plays FROM done_songs conn.commit()
WHERE Song_Id="""+str(song_id)+"), 0) + 1)" delete_entry(entry_id)
)
conn.commit()
delete_entry(entry_id)
conn.close()
return True return True
def toggle_transferred(entry_id): def toggle_transferred(entry_id):
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() cur = conn.execute(text("SELECT Transferred FROM entries WHERE ID = :par_entry_id"),
cur.execute("SELECT Transferred FROM entries WHERE ID =?", (entry_id,)) {"par_entry_id": entry_id}) # type: ignore
marked = cur.fetchall()[0][0] marked = cur.fetchall()[0][0]
if(marked == 0): if (marked == 0):
cur.execute( conn.execute(text("UPDATE entries SET Transferred = 1 WHERE ID = :par_entry_id"),
"UPDATE entries SET Transferred = 1 WHERE ID =?", (entry_id,)) {"par_entry_id": entry_id}) # type: ignore
else: else:
cur.execute( conn.execute(text("UPDATE entries SET Transferred = 0 WHERE ID = :par_entry_id"),
"UPDATE entries SET Transferred = 0 WHERE ID =?", (entry_id,)) {"par_entry_id": entry_id}) # type: ignore
conn.commit() conn.commit()
conn.close()
return True return True
def check_entry_quota(client_id): def check_entry_quota(client_id):
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() cur = conn.execute(text("SELECT Count(*) FROM entries WHERE entries.Client_Id = :par_client_id"),
cur.execute( {"par_client_id": client_id}) # type: ignore
"SELECT Count(*) FROM entries WHERE entries.Client_Id = ?", (client_id,))
return cur.fetchall()[0][0] return cur.fetchall()[0][0]
def check_queue_length(): def check_queue_length():
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() cur = conn.execute(text("SELECT Count(*) FROM entries"))
cur.execute("SELECT Count(*) FROM entries")
return cur.fetchall()[0][0] return cur.fetchall()[0][0]
def clear_played_songs(): def clear_played_songs():
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() conn.execute(text("DELETE FROM done_songs"))
cur.execute("DELETE FROM done_songs") conn.commit()
conn.commit()
conn.close()
return True return True
def delete_entry(id): def delete_entry(id):
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() conn.execute(text("DELETE FROM entries WHERE id= :par_id"), {
cur.execute("DELETE FROM entries WHERE id=?", (id,)) "par_id": id}) # type: ignore
conn.commit() conn.commit()
conn.close()
return True return True
@ -193,20 +202,61 @@ def delete_entries(ids):
for x in ids: for x in ids:
idlist.append((x,)) idlist.append((x,))
try: try:
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() cur = conn.execute(text("DELETE FROM entries WHERE id= :par_id"), {
cur.executemany("DELETE FROM entries WHERE id=?", idlist) "par_id": idlist})
conn.commit() conn.commit()
conn.close()
return cur.rowcount return cur.rowcount
except sqlite3.Error as error: except Exception as error:
return -1 return -1
def delete_all_entries(): def delete_all_entries() -> bool:
conn = open_db() with get_db_engine().connect() as conn:
cur = conn.cursor() conn.execute(text("DELETE FROM entries"))
cur.execute("DELETE FROM entries") conn.commit()
conn.commit()
conn.close()
return True return True
def get_config(key: str) -> str:
try:
with get_db_engine().connect() as conn:
cur = conn.execute(
text("SELECT `Value` FROM config WHERE `Key`= :par_key"), {"par_key": key}) # type: ignore
conn.commit()
return cur.fetchall()[0][0]
except IndexError as error:
return ""
def set_config(key: str, value: str) -> bool:
print(f"Setting config {key} to {value}")
with get_db_engine().connect() as conn:
conn.execute(text(
"INSERT INTO config (`Key`, `Value`) VALUES ( :par_key , :par_value) ON DUPLICATE KEY UPDATE `Value`= :par_value"),
{"par_key": key, "par_value": value}
) # type: ignore
conn.commit()
return True
def get_config_list() -> dict:
with get_db_engine().connect() as conn:
cur = conn.execute(text("SELECT * FROM config"))
result_dict = {}
for row in cur.fetchall():
result_dict[row[0]] = row[1]
return result_dict
def check_config_table() -> bool:
with get_db_engine().connect() as conn:
if conn.dialect.has_table(conn, 'config'):
# type: ignore
# type: ignore
if (conn.execute(text("SELECT COUNT(*) FROM config")).fetchone()[0] > 0): # type: ignore
return True
else:
return False
else:
return False

View File

@ -1,11 +0,0 @@
FROM tiangolo/uwsgi-nginx-flask:python3.7
RUN pip install requests
RUN pip install pandas
RUN pip install Flask-BasicAuth
RUN pip install bs4
COPY ../app /app

View File

@ -3,13 +3,15 @@ from bs4 import BeautifulSoup
import json import json
import os import os
import uuid import uuid
from flask import make_response from flask import make_response, Flask
from functools import wraps, update_wrapper from functools import wraps, update_wrapper
from datetime import datetime from datetime import datetime
import database
data_directory = "data" data_directory = "data"
config_file = data_directory+"/config.json" config_file = data_directory+"/config.json"
def create_data_directory(): def create_data_directory():
if not os.path.exists(data_directory): if not os.path.exists(data_directory):
os.makedirs(data_directory) os.makedirs(data_directory)
@ -18,13 +20,16 @@ def create_data_directory():
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')
url = soup.findAll('a', href=True, text='Verfügbar in CSV-Format')[0]['href'] url = soup.findAll(
'a', href=True, text='Verfügbar in CSV-Format')[0]['href']
return url return url
def get_songs(url): def get_songs(url):
r = requests.get(url) r = requests.get(url)
return r.text return r.text
def is_valid_uuid(val): def is_valid_uuid(val):
try: try:
uuid.UUID(str(val)) uuid.UUID(str(val))
@ -32,12 +37,14 @@ def is_valid_uuid(val):
except ValueError: except ValueError:
return False return False
def check_config_exists():
return os.path.isfile(config_file)
def load_version(app): def check_config_exists():
return database.check_config_table()
def load_version(app: Flask):
if os.environ.get("SOURCE_VERSION"): if os.environ.get("SOURCE_VERSION"):
app.config['VERSION'] = os.environ.get("SOURCE_VERSION")[0:7] app.config['VERSION'] = os.environ.get("SOURCE_VERSION")[0:7] # type: ignore # noqa: E501
elif os.path.isfile(".version"): elif os.path.isfile(".version"):
with open('.version', 'r') as file: with open('.version', 'r') as file:
data = file.read().replace('\n', '') data = file.read().replace('\n', '')
@ -48,32 +55,119 @@ def load_version(app):
else: else:
app.config['VERSION'] = "" app.config['VERSION'] = ""
def setup_config(app):
if check_config_exists(): def load_dbconfig(app: Flask):
config = json.load(open(config_file)) if os.environ.get("FLASK_ENV") == "development":
with open(config_file, 'r') as handle: app.config['DBCONNSTRING'] = os.environ.get("DBSTRING")
config = json.load(handle)
print("Loaded existing config")
else: else:
config = {'username': 'admin', 'password': 'changeme', 'entryquota': 3, 'maxqueue': 20} if os.environ.get("DEPLOYMENT_PLATFORM") == "Heroku":
with open(config_file, 'w') as handle: if os.environ.get("JAWSDB_MARIA_URL"):
json.dump(config, handle, indent=4, sort_keys=True) app.config['DBCONNSTRING'] = os.environ.get("JAWSDB_MARIA_URL")
print("Wrote new config") else:
app.config['DBCONNSTRING'] = ""
if os.environ.get("DEPLOYMENT_PLATFORM") == "Docker":
if os.environ.get("DBSTRING"):
app.config['DBCONNSTRING'] = os.environ.get("DBSTRING")
else:
app.config['DBCONNSTRING'] = ""
elif os.path.isfile(".dbconn"):
with open('.dbconn', 'r') as file:
data = file.read().replace('\n', '')
if data:
app.config['DBCONNSTRING'] = data
else:
app.config['DBCONNSTRING'] = ""
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.")
# Check if config exists in DB, if not, create it.
def setup_config(app: Flask):
if check_config_exists() == False:
print("No config found, creating new config")
initial_username = os.environ.get("INITIAL_USERNAME")
initial_password = os.environ.get("INITIAL_PASSWORD")
if initial_username is None:
print(
"No initial username set. Please set the environment variable INITIAL_USERNAME")
exit()
if initial_password is None:
print(
"No initial password set. Please set the environment variable INITIAL_PASSWORD")
exit()
default_config = {'username': initial_username,
'password': initial_password,
'entryquota': 3,
'maxqueue': 20,
'entries_allowed': 1,
'theme': 'default.css'}
for key, value in default_config.items():
database.set_config(key, value)
print("Created new config")
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']
app.config['ENTRY_QUOTA'] = config['entryquota'] app.config['ENTRY_QUOTA'] = config['entryquota']
app.config['MAX_QUEUE'] = config['maxqueue'] app.config['MAX_QUEUE'] = config['maxqueue']
app.config['ENTRIES_ALLOWED'] = bool(config['entries_allowed'])
app.config['THEME'] = config['theme']
# set queue admittance
def set_accept_entries(app: Flask, allowed: bool):
if allowed:
app.config['ENTRIES_ALLOWED'] = True
database.set_config('entries_allowed', '1')
else:
app.config['ENTRIES_ALLOWED'] = False
database.set_config('entries_allowed', '0')
# get queue admittance
def get_accept_entries(app: Flask) -> bool:
state = bool(int(database.get_config('entries_allowed')))
app.config['ENTRIES_ALLOWED'] = state
return state
# Write settings from current app.config to DB
def persist_config(app: Flask):
config = {'username': app.config['BASIC_AUTH_USERNAME'], 'password': app.config['BASIC_AUTH_PASSWORD'],
'entryquota': app.config['ENTRY_QUOTA'], 'maxqueue': app.config['MAX_QUEUE']}
for key, value in config.items():
database.set_config(key, value)
# Get available themes from themes directory
def get_themes():
themes = []
for theme in os.listdir('./static/css/themes'):
themes.append(theme)
return themes
# Set theme
def set_theme(app: Flask, theme: str):
if theme in get_themes():
app.config['THEME'] = theme
database.set_config('theme', theme)
else:
print("Theme not found, not setting theme.")
def nocache(view): def nocache(view):
@wraps(view) @wraps(view)
def no_cache(*args, **kwargs): def no_cache(*args, **kwargs):
response = make_response(view(*args, **kwargs)) response = make_response(view(*args, **kwargs))
response.headers['Last-Modified'] = datetime.now() response.headers['Last-Modified'] = datetime.now() # type: ignore
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0, max-age=0' response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0, max-age=0'
response.headers['Pragma'] = 'no-cache' response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '-1' response.headers['Expires'] = '-1'
return response return response
return update_wrapper(no_cache, view) return update_wrapper(no_cache, view)

View File

@ -1,5 +1,30 @@
requests autopep8
pandas beautifulsoup4
Flask-BasicAuth
bs4 bs4
gunicorn certifi
charset-normalizer
click
Flask
Flask-BasicAuth
greenlet
gunicorn
idna
itsdangerous
Jinja2
mariadb
MarkupSafe
mysql
mysqlclient
numpy
pandas
pycodestyle
PyMySQL
python-dateutil
pytz
requests
six
soupsieve
SQLAlchemy
toml
urllib3
Werkzeug

View File

@ -1,8 +1,36 @@
body { :root {
padding-top: 5rem; /* 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;
} }
html, body {
body {
padding-top: 5rem;
background-color: var(--background-color);
}
html,
body {
height: 100%; height: 100%;
} }
@ -12,7 +40,8 @@ html, body {
} }
main { main {
padding-bottom: 60px; /* Höhe des Footers */ padding-bottom: 60px;
/* Höhe des Footers */
} }
.footer { .footer {
@ -21,7 +50,7 @@ main {
height: 60px; height: 60px;
/* Set the fixed height of the footer here */ /* Set the fixed height of the footer here */
/*line-height: 60px; /* Vertically center the text there */ /*line-height: 60px; /* Vertically center the text there */
background-color: #f5f5f5; background-color: var(--background-color-var);
} }
.topbutton { .topbutton {
@ -56,6 +85,10 @@ table td:first-child {
max-width: 200px !important; max-width: 200px !important;
} }
.fa-solid {
vertical-align: auto;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.topbutton { .topbutton {
width: auto; width: auto;
@ -74,4 +107,88 @@ table td:first-child {
.admincontrols { .admincontrols {
display: none; 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

@ -0,0 +1,91 @@
.navbar {
background: #090a28 !important;
}
.navbar .navbar-toggle:hover,
.navbar .navbar-toggle:focus {
background-color: #900000 !important;
}
.navbar .navbar-toggle {
border: none;
}
.navbar .navbar-nav>.open>a,
.navbar .navbar-nav>.open>a:hover,
.navbar .navbar-nav>.open>a:focus {
color: #CF2323 !important;
background-color: #050515 !important;
}
.btn-primary {
background-color: #15175b;
border-color: #15175b;
}
.btn-primary:hover,
.btn-primary:focus {
background-color: #0e103e;
background-position: 0 -15px;
}
.btn-primary:active,
.btn-primary.active {
background-color: #0e103e;
border-color: #0e103e;
}
.btn-primary.disabled,
.btn-primary:disabled,
.btn-primary[disabled] {
background-color: #0e103e;
background-image: none;
}
.dropdown-menu>li>a:hover,
.dropdown-menu>li>a:focus {
color: #8A0711;
}
.dropdown-menu>.active>a,
.dropdown-menu>.active>a:hover,
.dropdown-menu>.active>a:focus {
background-color: #2e6da4;
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
background-repeat: repeat-x;
}
.navbar .navbar-nav>li>a:hover {
color: #b60000;
}
.form-control:focus {
border-color: rgba(21, 23, 91, 0.8);
outline: 0;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075),
0 0 8px rgba(21, 23, 91, 0.6);
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset,
0 0 8px rgba(21, 23, 91, 0.6);
}
a {
color: #900000;
}
a:hover,
a:focus {
color: #670000;
}
.navbar-brand {
display: flex;
align-items: center;
}
.navbar-brand > img {
height: 4rem;
}

View File

@ -6,13 +6,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content=""> <meta name="description" content="">
<meta name="author" content=""> <meta name="author" content="">
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="favicon.ico"> <link rel="icon" href="favicon.ico">
<link rel="manifest" href="/static/manifest.webmanifest"> <link rel="manifest" href="/static/manifest.webmanifest">
<title>{% block title %}{% endblock %} - KaraoQueue</title> <title>{% block title %}{% endblock %} - KaraoQueue</title>
<!-- Bootstrap-Tables --> <!-- Bootstrap-Tables -->
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.15.3/dist/bootstrap-table.min.css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.21.2/dist/bootstrap-table.min.css">
<!-- 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://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
@ -22,12 +23,16 @@
<link href="static/css/style.css" rel="stylesheet"> <link href="static/css/style.css" rel="stylesheet">
<!-- Fontawesome Icons --> <!-- Fontawesome Icons -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css" <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous"> integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Bootstraptoggle --> <!-- Bootstraptoggle -->
<link href="https://gitcdn.github.io/bootstrap-toggle/2.2.2/css/bootstrap-toggle.min.css" rel="stylesheet"> <link href="https://gitcdn.github.io/bootstrap-toggle/2.2.2/css/bootstrap-toggle.min.css" rel="stylesheet">
<!-- Active Theme -->
<link href="static/css/themes/{{config['THEME']}}" rel="stylesheet">
</head> </head>
<body> <body>
@ -73,9 +78,9 @@
{% if not auth %} {% if not auth %}
<a href="/login" class="ml-1 mr-1"><i class="fas fa-sign-in-alt mr-1"></i><span>Login</span></a> <a href="/login" class="ml-1 mr-1"><i class="fas fa-sign-in-alt mr-1"></i><span>Login</span></a>
{% endif %} {% endif %}
<!--<a href="https://github.com/PhoenixTwoFive/karaoqueue" <a href="https://github.com/PhoenixTwoFive/karaoqueue"
class="ml-1 mr-1"><i class="fab fa-github mr-1"></i><span>Github</span></a>--> class="ml-1 mr-1"><i class="fab fa-github mr-1"></i><span>Github</span></a>
<span class="text-muted">KaraoQueue {{karaoqueue_version}} -&nbsp;<span>&copy</span>&nbsp;2019-21 - Phillip <span class="text-muted"> {{karaoqueue_version}} -&nbsp;2019-23 - Phillip
Kühne</span> Kühne</span>
</div> </div>
</footer> </footer>
@ -96,9 +101,9 @@
<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>
<script src="https://unpkg.com/bootstrap-table@1.15.3/dist/bootstrap-table.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.21.2/dist/bootstrap-table.min.js"></script>
<script <script
src="https://unpkg.com/bootstrap-table@1.15.3/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>
{% block extrajs %}{% endblock %} {% block extrajs %}{% endblock %}
<script> <script>
@ -122,7 +127,7 @@
function loadOrGenerateClientId() { function loadOrGenerateClientId() {
if (!localStorage.getItem("clientId")) { if (!localStorage.getItem("clientId")) {
localStorage.setItem("clientId",create_UUID()) localStorage.setItem("clientId", create_UUID())
} }
} }
</script> </script>

View File

@ -1,15 +1,13 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Warteliste-Admin{% endblock %} {% block title %}Warteliste-Admin{% endblock %}
{% block content %} {% block content %}
<style> <style>
table td:nth-child(2) { table td:nth-child(2) {
overflow-y: hidden; overflow-y: hidden;
overflow-x: auto; overflow-x: auto;
text-overflow: clip; text-overflow: clip;
max-width: 200px !important; max-width: 200px !important;
} }
</style> </style>
<div class="container"> <div class="container">
<div id="toolbar"> <div id="toolbar">
@ -18,24 +16,13 @@ table td:nth-child(2) {
<button type="button" class="topbutton btn btn-danger" onclick="confirmUpdateSongDatabase()"><i <button type="button" class="topbutton btn btn-danger" onclick="confirmUpdateSongDatabase()"><i
class="fas fa-file-import mr-2"></i>Song-Datenbank class="fas fa-file-import mr-2"></i>Song-Datenbank
aktualisieren</button> aktualisieren</button>
<input id="entryToggle" type="checkbox" class="topbutton" data-toggle="toggle" data-on="Eintragen erlaubt" data-off="Eintragen deaktiviert" data-onstyle="success" data-offstyle="danger"> <input id="entryToggle" type="checkbox" class="topbutton" data-toggle="toggle" data-on="Eintragen erlaubt"
data-off="Eintragen deaktiviert" data-onstyle="success" data-offstyle="danger">
</div> </div>
<table class="table entries" <table class="table entries" id="entrytable" data-toggle="table" data-search="true" data-show-columns="true"
id="entrytable" data-show-toggle="true" data-multiple-select-row="true" data-click-to-select="true" data-toolbar="#toolbar"
data-toggle="table" data-pagination="true" data-show-extended-pagination="true" data-classes="table table-hover"
data-search="true" data-url="/api/queue" data-show-refresh="true" data-auto-refresh="true" data-auto-refresh-interval="10">
data-show-columns="true"
data-show-toggle="true"
data-multiple-select-row="true"
data-click-to-select="true"
data-toolbar="#toolbar"
data-pagination="true"
data-show-extended-pagination="true"
data-classes="table table-hover"
data-url="/api/queue"
data-show-refresh="true"
data-auto-refresh="true"
data-auto-refresh-interval="10">
<thead> <thead>
<tr> <tr>
<th data-field="state" data-checkbox="true"></th> <th data-field="state" data-checkbox="true"></th>
@ -52,16 +39,19 @@ table td:nth-child(2) {
{% block extrajs %} {% block extrajs %}
<script> <script>
$(function () { $(function () {
$('#entryToggle').change(function() {
$.ajax({url: "/api/entries/accept/"+($('#entryToggle').is(":checked") ? "1" : "0"), complete: setTimeout(refreshEntryToggle, 1000)});
})
refreshEntryToggle() refreshEntryToggle()
$("#entrytable").bootstrapTable().on('load-success.bs.table', function() { $('#entryToggle').change(function () {
$.ajax({ url: "/api/entries/accept/" + ($('#entryToggle').is(":checked") ? "1" : "0"), complete: setTimeout(refreshEntryToggle, 1000) });
})
$("#entrytable").bootstrapTable().on('load-success.bs.table', function () {
$('[data-toggle="tooltip"]').tooltip() $('[data-toggle="tooltip"]').tooltip()
}) })
$('[data-toggle="tooltip"]').tooltip({
trigger: 'hover'
})
}) })
function confirmDeleteEntry(name, entry_id) { function confirmDeleteEntry(name, entry_id) {
bootbox.confirm("Wirklich den Eintrag von "+name+" löschen?", function(result){ bootbox.confirm("Wirklich den Eintrag von " + name + " löschen?", function (result) {
if (result) { if (result) {
deleteEntry(entry_id) deleteEntry(entry_id)
} }
@ -69,7 +59,7 @@ table td:nth-child(2) {
} }
function confirmDeleteSelectedEntries() { function confirmDeleteSelectedEntries() {
bootbox.confirm({ bootbox.confirm({
message: "Wirklich gewählte Eintragungen löschen?", message: "Wirklich gewählte Eintragungen löschen?",
buttons: { buttons: {
confirm: { confirm: {
label: 'Ja', label: 'Ja',
@ -80,7 +70,7 @@ table td:nth-child(2) {
className: 'btn btn-secondary' className: 'btn btn-secondary'
} }
}, },
callback: function(result){ callback: function (result) {
if (result) { if (result) {
DeleteSelectedEntries(getIdSelections()) DeleteSelectedEntries(getIdSelections())
} }
@ -100,22 +90,22 @@ table td:nth-child(2) {
className: 'btn btn-secondary' className: 'btn btn-secondary'
} }
}, },
callback: function(result){ callback: function (result) {
if (result) { if (result) {
var dialog = bootbox.dialog({ var dialog = bootbox.dialog({
message: '<p class="text-center mb-0"><i class="fa fa-spin fa-cog"></i> Aktualisiere Song-Datenbank...</p>', message: '<p class="text-center mb-0"><i class="fa fa-spin fa-cog"></i> Aktualisiere Song-Datenbank...</p>',
closeButton: false closeButton: false
}); });
updateSongDatabase(dialog) updateSongDatabase(dialog)
} }
} }
}) })
} }
function refreshEntryToggle() { function refreshEntryToggle() {
$.getJSON("/api/entries/accept", (data) => { $.getJSON("/api/entries/accept", (data) => {
if (data["value"]!=$('#entryToggle').is(":checked")) { if (data["value"] != $('#entryToggle').is(":checked")) {
if(data["value"]==1) { if (data["value"] == 1) {
$('#entryToggle').data('bs.toggle').on('true') $('#entryToggle').data('bs.toggle').on('true')
} }
else { else {
@ -125,53 +115,53 @@ table td:nth-child(2) {
}) })
} }
function deleteEntry(entry_id) { function deleteEntry(entry_id) {
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: '/api/entries/delete/'+entry_id, url: '/api/entries/delete/' + entry_id,
contentType: "application/json", contentType: "application/json",
dataType: 'json', dataType: 'json',
async: false async: false
}); });
$("#entrytable").bootstrapTable('refresh') $("#entrytable").bootstrapTable('refresh')
} }
function markEntryAsSung(entry_id) { function markEntryAsSung(entry_id) {
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: '/api/entries/mark_sung/'+entry_id, url: '/api/entries/mark_sung/' + entry_id,
contentType: "application/json", contentType: "application/json",
dataType: 'json', dataType: 'json',
async: false async: false
}); });
$("#entrytable").bootstrapTable('refresh') $("#entrytable").bootstrapTable('refresh')
} }
function markEntryAsTransferred(entry_id) { function markEntryAsTransferred(entry_id) {
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: '/api/entries/mark_transferred/'+entry_id, url: '/api/entries/mark_transferred/' + entry_id,
contentType: "application/json", contentType: "application/json",
dataType: 'json', dataType: 'json',
async: false async: false
}); });
$("#entrytable").bootstrapTable('refresh') $("#entrytable").bootstrapTable('refresh')
} }
function DeleteSelectedEntries(ids) { function DeleteSelectedEntries(ids) {
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: '/api/entries/delete', url: '/api/entries/delete',
data: JSON.stringify(ids), // or JSON.stringify ({name: 'jonas'}), data: JSON.stringify(ids), // or JSON.stringify ({name: 'jonas'}),
error: function() { error: function () {
bootbox.alert({ bootbox.alert({
message: "Fehler beim Löschen der Eintragungen.", message: "Fehler beim Löschen der Eintragungen.",
}) })
}, },
success: function() { success: function () {
$("#entrytable").bootstrapTable('refresh') $("#entrytable").bootstrapTable('refresh')
}, },
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
@ -183,29 +173,32 @@ table td:nth-child(2) {
url: '/api/songs/update', url: '/api/songs/update',
contentType: "application/json", contentType: "application/json",
dataType: 'json', dataType: 'json',
success: function(data) { success: function (data) {
wait_dialog.modal('hide') wait_dialog.modal('hide')
bootbox.alert({ bootbox.alert({
message: data["status"], message: data["status"],
callback: function() { callback: function () {
$("#entrytable").bootstrapTable('refresh') $("#entrytable").bootstrapTable('refresh')
} }
}) })
} }
}); });
} }
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\" onclick=\"markEntryAsTransferred("+row.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=\"markEntryAsSung("+row.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=\"confirmDeleteEntry('"+row.Name+"',"+row.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>";
} else { } else {
outerHTML = "<button type=\"button\" class=\"btn btn-info\" onclick=\"markEntryAsTransferred("+row.ID+")\"><i class=\"fas fa-exchange-alt\"></i></button>&nbsp;<button type=\"button\" class=\"btn btn-success\" data-toggle=\"tooltip\" data-placement=\"top\" title=\"Als gesungen markieren\" onclick=\"markEntryAsSung("+row.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=\"confirmDeleteEntry('"+row.Name+"',"+row.ID+")\"><i class=\"fas fa-trash\"></i></button>"; outerHTML = "<button type=\"button\" class=\"btn btn-info\" data-toggle=\"tooltip\" data-placement=\"top\" title=\"Als übertragen markieren\" onclick=\"event.stopPropagation();$(this).tooltip('dispose');markEntryAsTransferred(" + row.entry_ID + ")\"><i class=\"fas fa-exchange-alt\"></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>";
} }
return outerHTML; return outerHTML;
} }
function getIdSelections() { function getIdSelections() {
return $.map($("#entrytable").bootstrapTable('getSelections'), function (row) { return $.map($("#entrytable").bootstrapTable('getSelections'), function (row) {
return row.ID return row.entry_ID
}) })
} }
</script> </script>

View File

@ -10,9 +10,32 @@
<label for="maxqueue">Maximale Anzahl an Einträgen Insgesamt</label> <label for="maxqueue">Maximale Anzahl an Einträgen Insgesamt</label>
<input type="number" class="form-control" id="maxqueue" name="maxqueue" min=1 value={{app.config['MAX_QUEUE']}}> <input type="number" class="form-control" id="maxqueue" name="maxqueue" min=1 value={{app.config['MAX_QUEUE']}}>
</p> </p>
<p>
<label for="theme">Aktives Theme</label>
<select class="form-control" id="theme" name="theme">
{% for theme in themes %}
<option value="{{theme}}" {% if theme == config['THEME'] %}selected{% endif %}>{{theme}}</option>
{% endfor %}
</select>
</p>
<div class="alert alert-warning" role="alert">
<i class="fas fa-exclamation-triangle mr-1"></i>
<strong>Warnung:</strong> Änderungen an den folgenden Einstellungen führen zu einer sofortigen Abmeldung!
</div>
<p>
<label for="username">Benutzername</label>
<input type="text" class="form-control" id="username" name="username" value={{app.config['BASIC_AUTH_USERNAME']}}>
</p>
<p>
<label for="password">Passwort ändern</label>
<input type="password" class="form-control" id="password" name="password">
</p>
<input type="submit" class="btn btn-primary mr-1 mb-2" value="Einstellungen anwenden"> <input type="submit" class="btn btn-primary mr-1 mb-2" value="Einstellungen anwenden">
<input type="button" class="btn btn-default mr-1 mb-2" onclick="$.get('/writeSettings').done(()=>{alert('Einstellungen gespeichert')}).fail(()=>{alert('Fehler beim Speichern der Einstellungen')})" value="Einstellungen speichern"/>
</form> </form>
<details>
<summary>Current config:</summary>
<pre>{% for key, val in config.items() %}{{key}}: {{val}}<br>{% endfor %}</pre>
</details>
{% endblock %} {% endblock %}
{% block extrajs %} {% block extrajs %}
{% endblock %} {% endblock %}

View File

@ -46,7 +46,7 @@
var items = []; var items = [];
$.each(data, function (key, val) { $.each(data, function (key, val) {
items.push("<tr><td>" + val[0] + `</td> items.push("<tr><td>" + val[0] + `</td>
<td><button type='button' <td class='buttoncell'><button type='button'
class='btn btn-primary justify-content-center align-content-between enqueueButton' class='btn btn-primary justify-content-center align-content-between enqueueButton'
data-toggle='modal' data-toggle='modal'
data-target='#enqueueModal' onclick='setSelectedId(`+ val[1] + `)'><i data-target='#enqueueModal' onclick='setSelectedId(`+ val[1] + `)'><i

23
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,23 @@
version: "3.9"
secrets:
secrets:
file: ./secrets.yml
services:
karaoqueue:
image: "ghcr.io/phoenixtwofive/karaoqueue:v2023.03.1"
build: .
restart: always
ports:
- "127.0.0.1:8081:80" # Please put a reverse proxy in front of this
env_file: .env
db:
image: mariadb
restart: always
env_file: .env
volumes:
- karaoqueue-db:/var/lib/mysql
volumes:
karaoqueue-db:

15
docker-compose.yml Normal file
View File

@ -0,0 +1,15 @@
# This Compose file is for development only. It is not intended for production use.
# It only starts auxiliary services, such as a database, that are required for the
# application to run. The application itself is started separately, using the
# command "python -m flask run" or your favorite IDE.
# Useful for attaching a debugger to the application.
version: "3.9"
services:
db:
image: mariadb
restart: always
env_file: .env.dev
ports:
- "3306:3306"

View File

@ -1,392 +0,0 @@
openapi: 3.0.2
info:
title: Karaoqueue API
version: '0.0.1'
servers:
- url: 'http://localhost:3000/api'
description: Local Test sever instance
- url: 'https://karaoke.phillipathome.dynv6.net/api'
description: Production API
paths:
/queue:
get:
summary: 'Fetch entry Queue content'
description: 'Fetch entry Queue'
parameters:
- name: index
in: query
description: Position from which on to return results
required: false
schema:
type: integer
- name: limit
in: query
description: How many items to return at one time (max 100, default 20)
required: false
schema:
type: integer
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/QueueEntry'
'400':
description: Invalid request. Check your parameters.
'404':
description: No Entries found in specified range.
'5XX':
description: Unexpected error.
post:
description: 'Add entry to Queue'
summary: 'Add entry to Queue'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
singer_name:
type: string
song_id:
type: integer
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
entry_id:
type: string
format: bson.ObjectID
pattern: '/^[a-f\d]{24}$/i'
entry_auth:
type: string
'400':
description: Malformed request.
'405':
description: Currently not accepting entries.
delete:
summary: 'Clear queue'
security:
- cookieAuth: []
responses:
'200':
description: OK. Successfully cleared Queue
'401':
description: Not Authorized
description: clear queue
/queue/{entry_id}:
get:
summary: GET single queue entry
parameters:
- in: path
name: entry_id
schema:
type: integer
required: true
description: ID of the Entry to get
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/QueueEntry'
patch:
summary: Change entry
security:
- cookieAuth: []
parameters:
- in: path
name: entry_id
schema:
type: string
format: bson.ObjectID
pattern: '/^[a-f\d]{24}$/i'
required: true
description: >
ID of the entry to modify. One of the following is needed:
- Proper Bearer-Token authorization
- The entry_auth string corresponding to the entry
requestBody:
required: false
content:
application/json:
schema:
type: object
required:
- entry_auth
properties:
singer_name:
type: string
song_id:
type: integer
entry_auth:
type: string
responses:
'200':
description: OK
'404':
description: Entry not found
'405':
description: Method not allowed. Check your entry_auth or authorization.
delete:
summary: 'Delete entry'
security:
- cookieAuth: []
parameters:
- in: path
name: entry_id
schema:
type: string
format: bson.ObjectID
pattern: '/^[a-f\d]{24}$/i'
required: true
description: >
ID of the entry to modify. One of the following is needed:
- Proper Bearer-Token authorization
- The entry_auth string corresponding to the entry
requestBody:
required: false
content:
application/json:
schema:
type: object
required:
- entry_auth
properties:
entry_auth:
type: string
responses:
'200':
description: OK
'404':
description: Entry not found
'405':
description: Method not allowed. Check your entry_auth or authorization.
/songs:
get:
summary: Search in Songs
parameters:
- in: query
name: query
schema:
type: string
required: true
- name: limit
in: query
description: How many items to return at one time (max 100, default 20)
required: false
schema:
type: integer
responses:
'200':
description: OK. An array of Songs according to the Query string.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SongEntry'
'400':
description: Malformed Request
put:
summary: Update Song Database
description: >
Trigger an update of the database using the source CSV defined
in the config.
security:
- cookieAuth: []
responses:
'200':
description: OK. Songs have been updated
'401':
description: Authorization required. Check your auth.
/statistics:
get:
summary: Statistics about the Database
responses:
'200':
description: Statistics as JSON
content:
application/json:
schema:
type: object
properties:
num_songs:
type: integer
num_entries:
type: integer
/auth/login:
post:
summary: Logs in and returns the authentication cookie
requestBody:
required: true
description: A JSON object containing the login and password.
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
security: [] # no authentication
responses:
'200':
description: >
Successfully authenticated.
The session ID is returned in a cookie named `jwt`. You need to include this cookie in subsequent requests.
headers:
Set-Cookie:
schema:
type: string
example: jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiJhZG1pbiIsImlhdCI6MTYwMTY1MDYwNSwiZXhwIjoxNjAxNjU0MjA1fQ.uGvOlBAZdbPT8U9s7jEt5PUWyxLrpgaf02EoPVC_Zlsd; Path=/; HttpOnly
/auth/logout:
get:
summary: Logs the user out and invalidates the session on the server
security:
- cookieAuth: []
responses:
'200':
description: OK.
'401':
description: Authorization required.
/rpc/end_event:
get:
summary: End the current event
description: Locks entries and does not allow reopening without reset
security:
- cookieAuth: []
responses:
'200':
description: OK.
/rpc/start_event:
get:
summary: Start new event. Clears entries and stats.
description: Sets up a clean state.
security:
- cookieAuth: []
responses:
'200':
description: OK.
/rpc/enable_registration:
get:
summary: Enables registration in the queue
description: Makes it possible for guests to register in the queue.
security:
- cookieAuth: []
responses:
'200':
description: OK.
/rpc/disable_registration:
get:
summary: Disables registration in the queue
description: Makes it impossible for guests to register in the queue.
security:
- cookieAuth: []
responses:
'200':
description: OK.
/rpc/get_playstats:
get:
summary: Get stats of played songs in the current event.
description: Returns the stats for the current evening as JSON
security:
- cookieAuth: []
responses:
'200':
description: OK.
/rpc/download_playstats:
get:
summary: Get stats of played songs in the current event for download.
description: Returns the stats for the current evening as PDF (for example for GEMA)
security:
- cookieAuth: []
responses:
'200':
description: OK.
/rpc/entry_fulfilled:
get:
summary: Mark an entry as fulfilled.
description: Mark an entry as fulfilled. This adds it to the statistics.
security:
- cookieAuth: []
responses:
'200':
description: OK.
parameters:
- in: query
name: entry_id
schema:
type: string
description: The id of the entry to mark as done.
components:
securitySchemes:
cookieAuth: # arbitrary name for the security scheme; will be used in the "security" key later
type: apiKey
in: cookie
name: jwt # cookie name
schemas:
QueueEntry:
type: object
properties:
_id:
type: string
format: bson.ObjectID
pattern: '/^[a-f\d]{24}$/i'
singer_name:
type: string
song_id:
type: integer
SongEntry:
type: object
properties:
id:
type: integer
title:
type: string
artist:
type: string
year:
type: integer
duet:
type: boolean
explicit:
type: boolean
styles:
type: array
items:
type: string
languages:
type: array
items:
type: string
LoginRequest:
type: object
properties:
username:
type: string
password:
type: string
format: password

View File

@ -1,25 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "firefox",
"request": "launch",
"reAttach": true,
"name": "Launch Firefox against localhost",
"url": "http://localhost:4200",
"webRoot": "${workspaceFolder}"
},
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:4200",
"webRoot": "${workspaceFolder}",
"runtimeExecutable": "/usr/bin/chromium"
}
]
}

View File

@ -1,26 +0,0 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"group": "build",
"problemMatcher": []
},
{
"type": "npm",
"script": "build",
"group": "build",
"problemMatcher": []
},
{
"type": "npm",
"script": "serve",
"problemMatcher": [
"$tsc"
]
}
]
}

View File

@ -1,135 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"ng-karaoqueue": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/ng-karaoqueue",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"aot": false,
"assets": [
"src/favicon.ico",
"src/assets",
{
"glob": "mdi.svg",
"input": "node_modules/@mdi/angular-material/",
"output": "/assets/"
}
],
"styles": [
"src/custom-theme.scss",
"src/styles.scss"
],
"scripts": [],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
},
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "ng-karaoqueue:build"
},
"configurations": {
"production": {
"browserTarget": "ng-karaoqueue:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "ng-karaoqueue:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/favicon.ico",
"src/assets",
{
"glob": "mdi.svg",
"input": "node_modules/@mdi/angular-material/",
"output": "/assets/"
}
],
"styles": [
"./app/custom-theme.scss",
"src/styles.scss"
],
"scripts": []
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "ng-karaoqueue:serve"
},
"configurations": {
"production": {
"devServerTarget": "ng-karaoqueue:serve:production"
}
}
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +0,0 @@
{
"name": "ng-karaoqueue",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"serve": "ng serve --host=0.0.0.0",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular/animations": "^14.2.3",
"@angular/cdk": "^14.2.2",
"@angular/common": "^14.2.3",
"@angular/compiler": "^14.2.3",
"@angular/core": "^14.2.3",
"@angular/forms": "^14.2.3",
"@angular/material": "^14.2.2",
"@angular/platform-browser": "^14.2.3",
"@angular/platform-browser-dynamic": "^14.2.3",
"@angular/router": "^14.2.3",
"@mdi/angular-material": "^7.0.96",
"hammerjs": "^2.0.8",
"rxjs": "~7.5.6",
"tslib": "^2.4.0",
"zone.js": "~0.11.8"
},
"devDependencies": {
"@angular/cli": "^14.2.3",
"@angular/compiler-cli": "^14.2.3",
"@angular/language-service": "^14.2.3",
"@types/jasmine": "~4.3.0",
"@types/jasminewd2": "~2.0.10",
"@types/node": "~18.7.19",
"codelyzer": "^0.0.28",
"jasmine-core": "~4.4.0",
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.4.1",
"karma-chrome-launcher": "~3.1.1",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "^2.0.0",
"protractor": "~7.0.0",
"ts-node": "~10.9.1",
"tslint": "~6.1.0",
"typescript": "~4.8.3"
}
}

View File

@ -1,16 +0,0 @@
import { EntryListingComponent } from './entry-listing/entry-listing.component';
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SongSearchComponent } from './song-search/song-search.component';
const routes: Routes = [
{path: '', component: EntryListingComponent },
{path: 'songs', component: SongSearchComponent}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@ -1,11 +0,0 @@
<div>
<mat-toolbar color="primary" class="mat-elevation-z6">
<span>KaraoQueue</span>
<span>&nbsp;</span>
<a mat-button routerLink="/" [routerLinkActive]="['is-active']">Warteschlange</a>
<a mat-button routerLink="/songs" [routerLinkActive]="['is-active']">Songs</a>
<div class="spacer"></div>
<a mat-stroked-button>Login</a>
</mat-toolbar>
</div>
<router-outlet></router-outlet>

View File

@ -1,7 +0,0 @@
.spacer {
flex: 1 1 auto;
}
.is-active {
background-color: rgba(255,255,255,0.1);
}

View File

@ -1,16 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { RuntimeConfigLoaderService } from 'runtime-config-loader';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit{
constructor(private configSvc: RuntimeConfigLoaderService) {}
ngOnInit(): void {
console.log("API at ",this.configSvc.getConfigObjectKey("api"));
}
title = 'KaraoQueue';
}

View File

@ -1,107 +0,0 @@
import { BrowserModule, DomSanitizer } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatBadgeModule } from '@angular/material/badge';
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatStepperModule } from '@angular/material/stepper';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatGridListModule } from '@angular/material/grid-list';
import { MatIconRegistry, MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatMenuModule } from '@angular/material/menu';
import { MatNativeDateModule, MatRippleModule } from '@angular/material/core';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSliderModule } from '@angular/material/slider';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatTreeModule } from '@angular/material/tree';
import { HttpClientModule } from '@angular/common/http';
import { EntryListingComponent } from './entry-listing/entry-listing.component';
import { SongSearchComponent } from './song-search/song-search.component';
import { debounceTime, distinctUntilChanged } from "rxjs/operators";
import { RuntimeConfigLoaderModule } from 'runtime-config-loader';
@NgModule({
declarations: [
AppComponent,
EntryListingComponent,
SongSearchComponent
],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
MatAutocompleteModule,
MatBadgeModule,
MatBottomSheetModule,
MatButtonModule,
MatButtonToggleModule,
MatCardModule,
MatCheckboxModule,
MatChipsModule,
MatStepperModule,
MatDatepickerModule,
MatDialogModule,
MatDividerModule,
MatExpansionModule,
MatGridListModule,
MatIconModule,
MatInputModule,
MatListModule,
MatMenuModule,
MatNativeDateModule,
MatPaginatorModule,
MatProgressBarModule,
MatProgressSpinnerModule,
MatRadioModule,
MatRippleModule,
MatSelectModule,
MatSidenavModule,
MatSliderModule,
MatSlideToggleModule,
MatSnackBarModule,
MatSortModule,
MatTableModule,
MatTabsModule,
MatToolbarModule,
MatTooltipModule,
MatTreeModule,
HttpClientModule,
RuntimeConfigLoaderModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
constructor(matIconRegistry: MatIconRegistry, domSanitizer: DomSanitizer) {
matIconRegistry.addSvgIconSet(domSanitizer.bypassSecurityTrustResourceUrl('./assets/mdi.svg'));
}
}

View File

@ -1 +0,0 @@
<p>entry-listing works!</p>

View File

@ -1,25 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { EntryListingComponent } from './entry-listing.component';
describe('EntryListingComponent', () => {
let component: EntryListingComponent;
let fixture: ComponentFixture<EntryListingComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ EntryListingComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EntryListingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,15 +0,0 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-entry-listing',
templateUrl: './entry-listing.component.html',
styleUrls: ['./entry-listing.component.scss']
})
export class EntryListingComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { EntryServiceService } from './entry-service.service';
describe('EntryServiceService', () => {
let service: EntryServiceService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(EntryServiceService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -1,25 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { RuntimeConfigLoaderService } from 'runtime-config-loader';
import { Entry } from './models/entry.model';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class EntryServiceService {
private api: string;
constructor(
private http: HttpClient,
private configSvc: RuntimeConfigLoaderService
) {
this.api = configSvc.getConfigObjectKey("api");
}
getEntries(): Observable<Array<Entry>> {
return null; // TODO
}
}

View File

@ -1,10 +0,0 @@
export class Artist {
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
id: number;
name: string;
}

View File

@ -1,12 +0,0 @@
export class Entry {
constructor(singer_name: string, song: string, auth_cookie?: string) {
this.singer_name=singer_name;
this.song=song;
this.auth_cookie = auth_cookie;
}
singer_name: string;
song: string; //Actually the ID of the Song
auth_cookie?: string; //The "cookie" to authenticate for changing an entry. Null otherwise
}

View File

@ -1,16 +0,0 @@
export class Genre {
constructor(id: number, name: string) {
this.id = id;
this._name = name;
}
public get name() : string {
return this._name;
}
id: number;
_name: string;
}

View File

@ -1,10 +0,0 @@
export class Language {
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
id: number;
name: string;
}

View File

@ -1,26 +0,0 @@
import { Genre } from './genre.model';
import { Language } from './language.model';
import { Artist } from './artist.model';
export class Song {
constructor(title: string, artist: Artist, karafun_id: number, duet: boolean, explicit: boolean, id: number, genres: Array<String>, languages: Array<String>) {
this.title=title;
this.artist=artist;
this.karafun_id=karafun_id;
this.duet=duet;
this.explicit=explicit;
this.id=id;
this.genres=genres;
this.languages=languages;
}
title: string;
artist: Artist;
karafun_id: number;
duet: boolean;
explicit: boolean;
id: number;
genres: Array<String>;
languages: Array<String>;
}

View File

@ -1,48 +0,0 @@
<div class="content">
<mat-form-field class="search-input">
<mat-label>Suche...</mat-label>
<mat-icon matSuffix svgIcon="magnify"></mat-icon>
<input type="text" matInput placeholder="Tippe einen Künstler oder Song..."
(keyup)="applyFilter($event.target.value)">
</mat-form-field>
</div>
<div class="resultcontainer">
<mat-list *ngFor="let song of songs">
<mat-card class="result">
<div class="card-left">
<mat-card-title>{{song.artist}} - {{song.title}}</mat-card-title>
<div class="song-info">
<div class="info-icons">
<ng-template [ngIf]="song.duet==true" [ngIfElse]="nonduet">
<mat-icon svgIcon="account-multiple"></mat-icon>
</ng-template>
<ng-template #nonduet>
<mat-icon svgIcon="account"></mat-icon>
</ng-template>
<ng-template [ngIf]="song.explicit==true" [ngIfElse]="nonexplicit">
<mat-icon svgIcon="alpha-e-box"></mat-icon>
</ng-template>
<ng-template #nonexplicit>
<mat-icon svgIcon="alpha-e-box" class="icon-disabled"></mat-icon>
</ng-template>
</div>
<div class="genre-list">
<mat-icon svgIcon="music-circle"></mat-icon>
<mat-chip-list *ngFor="let genre of song.genres">
<mat-chip>{{genre}}</mat-chip>
</mat-chip-list>
</div>
<div class="language-list">
<mat-icon svgIcon="account-voice"></mat-icon>
<mat-chip-list *ngFor="let language of song.languages">
<mat-chip>{{language}}</mat-chip>
</mat-chip-list>
</div>
</div>
</div>
<button mat-flat-button color="accent" class="add-button">
<mat-icon svgIcon="playlist-plus"></mat-icon>
</button>
</mat-card>
</mat-list>
</div>

View File

@ -1,120 +0,0 @@
.search-input {
width: 95%;
margin-top: 1rem;
}
.add-button {
height: 3rem;
}
.content {
overflow: hidden;
display: flex;
justify-content: center;
}
.resultcontainer {
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
}
.result {
margin-left: auto;
margin-right: auto;
width: 90%;
margin-top: 0.2rem;
display: flex;
align-items: stretch;
justify-content: space-between;
flex-direction: column;
}
.card-left {
display: flex;
flex-direction: column;
width: 100%;
}
.song-info {
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
}
.song-info > * {
margin-right: 0.5rem;
}
.icon-disabled {
opacity: 0.1;
}
.genre-list > mat-icon {
padding-right: 0.25rem;
}
.genre-list mat-chip-list {
margin-right: 0.15rem;
}
.genre-list {
overflow-x: scroll;
overflow-y: hidden;
white-space: nowrap;
display: flex;
flex-direction: row;
padding-bottom: 0.25rem;
align-items: center;
-webkit-mask-image: linear-gradient(to right, rgba(0,0,0,0) 0%,rgba(0,0,0,1) 2px,rgba(0,0,0,1) calc(100% - 2px),rgba(0,0,0,0) 100%);
mask-image: linear-gradient(to right, rgba(0,0,0,0) 0%,rgba(0,0,0,1) 2px,rgba(0,0,0,1) calc(100% - 2px),rgba(0,0,0,0) 100%);
-ms-overflow-style: none;
}
.language-list > mat-icon {
padding-right: 0.25rem;
}
.language-list mat-chip-list {
margin-right: 0.15rem;
}
.genre-list::-webkit-scrollbar {
width: 0px;
height: 0px;
display: none;
}
.language-list {
overflow-x: scroll;
overflow-y: hidden;
white-space: nowrap;
display: flex;
flex-direction: row;
padding-bottom: 0.25rem;
align-items: center;
-ms-overflow-style: none;
}
.language-list::-webkit-scrollbar {
display: none;
}
$breakpoint-tablet: 768px;
@media (min-width: $breakpoint-tablet) {
.result {
flex-direction: row;
}
.add-button {
height: 6.5rem;
width: 4rem;
align-self: center;
}
}

View File

@ -1,25 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SongSearchComponent } from './song-search.component';
describe('SongSearchComponent', () => {
let component: SongSearchComponent;
let fixture: ComponentFixture<SongSearchComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SongSearchComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SongSearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,43 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { SongServiceService } from "../song-service.service";
import { Song } from '../models/song.model';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
@Component({
selector: 'app-song-search',
templateUrl: './song-search.component.html',
styleUrls: ['./song-search.component.scss']
})
export class SongSearchComponent implements OnInit {
private searchSub$ = new Subject<string>();
constructor(private songServiceService: SongServiceService) { }
songs: Array<Song> = new Array<Song>();
updateSongs(text: string) {
this.songServiceService.searchSongByText(text).subscribe(x => {
console.log(x);
this.songs = x;
});
}
applyFilter(filterValue: string) {
this.searchSub$.next(filterValue)
}
ngOnInit(): void {
this.searchSub$.pipe(
debounceTime(400),
distinctUntilChanged()
).subscribe((filterValue: string) => {
this.updateSongs(filterValue);
});
//Für Testzwecke
this.updateSongs("Test");
}
}

View File

@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { SongServiceService } from './song-service.service';
describe('SongServiceService', () => {
let service: SongServiceService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(SongServiceService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -1,42 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { Song } from './models/song.model';
import { Artist } from './models/artist.model';
import { Genre } from './models/genre.model';
import { Language } from './models/language.model';
import { RuntimeConfigLoaderService } from 'runtime-config-loader';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class SongServiceService {
private api: string;
constructor(
private http: HttpClient,
private configSvc: RuntimeConfigLoaderService
) {
this.api=configSvc.getConfigObjectKey("api");
}
searchSongByText(text: string): Observable<Array<Song>> {
let out = new Array<Song>();
this.http.get(this.api +"/songs/compl?search="+text).subscribe((data: Observable<JSON>) => {
data.forEach(element => {
out.push(new Song(element["title"],element["artist"],element["karafun_id"],element["duo"],element["explicit"],element["_id"],element["styles"],element["languages"]));
});
});
const observable = new Observable<Array<Song>>( subscriber => {
subscriber.next(out);
})
return observable;
}
}

View File

@ -1,3 +0,0 @@
{
"api":"http://localhost:5000/api"
}

View File

@ -1,280 +0,0 @@
/**
* Generated theme by Material Theme Generator
* https://materialtheme.arcsine.dev
*/
@import '@angular/material/theming';
// Include the common styles for Angular Material. We include this here so that you only
// have to load a single css file for Angular Material in your app.
// Fonts
@import 'https://fonts.googleapis.com/css?family=Material+Icons';
@import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500');
$fontConfig: (
display-4: mat-typography-level(112px, 112px, 300, 'Roboto', -0.0134em),
display-3: mat-typography-level(56px, 56px, 400, 'Roboto', -0.0089em),
display-2: mat-typography-level(45px, 48px, 400, 'Roboto', 0.0000em),
display-1: mat-typography-level(34px, 40px, 400, 'Roboto', 0.0074em),
headline: mat-typography-level(24px, 32px, 400, 'Roboto', 0.0000em),
title: mat-typography-level(20px, 32px, 500, 'Roboto', 0.0075em),
subheading-2: mat-typography-level(16px, 28px, 400, 'Roboto', 0.0094em),
subheading-1: mat-typography-level(15px, 24px, 500, 'Roboto', 0.0067em),
body-2: mat-typography-level(14px, 24px, 500, 'Roboto', 0.0179em),
body-1: mat-typography-level(14px, 20px, 400, 'Roboto', 0.0179em),
button: mat-typography-level(14px, 14px, 500, 'Roboto', 0.0893em),
caption: mat-typography-level(12px, 20px, 400, 'Roboto', 0.0333em),
input: mat-typography-level(inherit, 1.125, 400, 'Roboto', 1.5px)
);
// Foreground Elements
// Light Theme Text
$dark-text: #000000;
$dark-primary-text: rgba($dark-text, 0.87);
$dark-accent-text: rgba($dark-primary-text, 0.54);
$dark-disabled-text: rgba($dark-primary-text, 0.38);
$dark-dividers: rgba($dark-primary-text, 0.12);
$dark-focused: rgba($dark-primary-text, 0.12);
$mat-light-theme-foreground: (
base: black,
divider: $dark-dividers,
dividers: $dark-dividers,
disabled: $dark-disabled-text,
disabled-button: rgba($dark-text, 0.26),
disabled-text: $dark-disabled-text,
elevation: black,
secondary-text: $dark-accent-text,
hint-text: $dark-disabled-text,
accent-text: $dark-accent-text,
icon: $dark-accent-text,
icons: $dark-accent-text,
text: $dark-primary-text,
slider-min: $dark-primary-text,
slider-off: rgba($dark-text, 0.26),
slider-off-active: $dark-disabled-text,
);
// Dark Theme text
$light-text: #ffffff;
$light-primary-text: $light-text;
$light-accent-text: rgba($light-primary-text, 0.7);
$light-disabled-text: rgba($light-primary-text, 0.5);
$light-dividers: rgba($light-primary-text, 0.12);
$light-focused: rgba($light-primary-text, 0.12);
$mat-dark-theme-foreground: (
base: $light-text,
divider: $light-dividers,
dividers: $light-dividers,
disabled: $light-disabled-text,
disabled-button: rgba($light-text, 0.3),
disabled-text: $light-disabled-text,
elevation: black,
hint-text: $light-disabled-text,
secondary-text: $light-accent-text,
accent-text: $light-accent-text,
icon: $light-text,
icons: $light-text,
text: $light-text,
slider-min: $light-text,
slider-off: rgba($light-text, 0.3),
slider-off-active: rgba($light-text, 0.3),
);
// Background config
// Light bg
$light-background: #fafafa;
$light-bg-darker-5: darken($light-background, 5%);
$light-bg-darker-10: darken($light-background, 10%);
$light-bg-darker-20: darken($light-background, 20%);
$light-bg-darker-30: darken($light-background, 30%);
$light-bg-lighter-5: lighten($light-background, 5%);
$dark-bg-tooltip: lighten(#2c2c2c, 20%);
$dark-bg-alpha-4: rgba(#2c2c2c, 0.04);
$dark-bg-alpha-12: rgba(#2c2c2c, 0.12);
$mat-light-theme-background: (
background: $light-background,
status-bar: $light-bg-darker-20,
app-bar: $light-bg-darker-5,
hover: $dark-bg-alpha-4,
card: $light-bg-lighter-5,
dialog: $light-bg-lighter-5,
tooltip: $dark-bg-tooltip,
disabled-button: $dark-bg-alpha-12,
raised-button: $light-bg-lighter-5,
focused-button: $dark-focused,
selected-button: $light-bg-darker-20,
selected-disabled-button: $light-bg-darker-30,
disabled-button-toggle: $light-bg-darker-10,
unselected-chip: $light-bg-darker-10,
disabled-list-option: $light-bg-darker-10,
);
// Dark bg
$dark-background: #2c2c2c;
$dark-bg-lighter-5: lighten($dark-background, 5%);
$dark-bg-lighter-10: lighten($dark-background, 10%);
$dark-bg-lighter-20: lighten($dark-background, 20%);
$dark-bg-lighter-30: lighten($dark-background, 30%);
$light-bg-alpha-4: rgba(#fafafa, 0.04);
$light-bg-alpha-12: rgba(#fafafa, 0.12);
// Background palette for dark themes.
$mat-dark-theme-background: (
background: $dark-background,
status-bar: $dark-bg-lighter-20,
app-bar: $dark-bg-lighter-5,
hover: $light-bg-alpha-4,
card: $dark-bg-lighter-5,
dialog: $dark-bg-lighter-5,
tooltip: $dark-bg-lighter-20,
disabled-button: $light-bg-alpha-12,
raised-button: $dark-bg-lighter-5,
focused-button: $light-focused,
selected-button: $dark-bg-lighter-20,
selected-disabled-button: $dark-bg-lighter-30,
disabled-button-toggle: $dark-bg-lighter-10,
unselected-chip: $dark-bg-lighter-20,
disabled-list-option: $dark-bg-lighter-10,
);
// Compute font config
@include mat-core($fontConfig);
// Theme Config
body {
--primary-color: #090a28;
--primary-lighter-color: #b5b6bf;
--primary-darker-color: #050518;
--text-primary-color: #{$light-primary-text};
--text-primary-lighter-color: #{$dark-primary-text};
--text-primary-darker-color: #{$light-primary-text};
}
$mat-primary: (
main: #090a28,
lighter: #b5b6bf,
darker: #050518,
200: #090a28, // For slide toggle,
contrast : (
main: $light-primary-text,
lighter: $dark-primary-text,
darker: $light-primary-text,
)
);
$theme-primary: mat-palette($mat-primary, main, lighter, darker);
body {
--accent-color: #b70000;
--accent-lighter-color: #e9b3b3;
--accent-darker-color: #9f0000;
--text-accent-color: #{$light-primary-text};
--text-accent-lighter-color: #{$dark-primary-text};
--text-accent-darker-color: #{$light-primary-text};
}
$mat-accent: (
main: #b70000,
lighter: #e9b3b3,
darker: #9f0000,
200: #b70000, // For slide toggle,
contrast : (
main: $light-primary-text,
lighter: $dark-primary-text,
darker: $light-primary-text,
)
);
$theme-accent: mat-palette($mat-accent, main, lighter, darker);
body {
--warn-color: #ffc600;
--warn-lighter-color: #ffeeb3;
--warn-darker-color: #ffb100;
--text-warn-color: #{$dark-primary-text};
--text-warn-lighter-color: #{$dark-primary-text};
--text-warn-darker-color: #{$dark-primary-text};
}
$mat-warn: (
main: #ffc600,
lighter: #ffeeb3,
darker: #ffb100,
200: #ffc600, // For slide toggle,
contrast : (
main: $dark-primary-text,
lighter: $dark-primary-text,
darker: $dark-primary-text,
)
);
$theme-warn: mat-palette($mat-warn, main, lighter, darker);;
$theme: mat-dark-theme($theme-primary, $theme-accent, $theme-warn);
$altTheme: mat-light-theme($theme-primary, $theme-accent, $theme-warn);
// Theme Init
@include angular-material-theme($theme);
.theme-alternate {
@include angular-material-theme($altTheme);
}
// Specific component overrides, pieces that are not in line with the general theming
// Handle buttons appropriately, with respect to line-height
.mat-raised-button, .mat-stroked-button, .mat-flat-button {
padding: 0 1.15em;
margin: 0 .65em;
min-width: 3em;
line-height: 36.4px
}
.mat-standard-chip {
padding: .5em .85em;
min-height: 2.5em;
}
.material-icons {
font-size: 24px;
font-family: 'Material Icons', 'Material Icons';
.mat-badge-content {
font-family: 'Roboto';
}
}
mat-form-field.mat-form-field {
&.mat-focused {
.mat-form-field-label {
color: $light-text;
}
.mat-form-field-ripple {
background-color: $light-text !important;
}
}
.mat-form-field-label.mat-focused,
.mat-form-field.mat-focused.matform-field-should-float {
color: $light-text;
}
.mat-form-field-underline, .mat-form-field-label {
color: $light-accent-text;
}
.mat-form-field-underline.mat-focused {
background-color: $light-text;
}
}
button.mat-menu-item {
line-height: 24px !important;
}
a.mat-menu-item > mat-icon {
margin-bottom: 14px;
}
.mat-icon svg {
height: 24px;
width: 24px;
}

View File

@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>NgKaraoqueue</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography mat-app-background">
<app-root></app-root>
</body>
</html>

View File

@ -1,10 +0,0 @@
/* You can add global styles to this file, and also import other style files */
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
}

View File

@ -1,14 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/app/app.module.ts",
],
"include": [
"src/**/*.d.ts"
]
}

View File

@ -1,32 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "es2020",
"module": "es2020",
"lib": [
"es2020",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@ -1,18 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"src/test.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}