Merge pull request 'Showreel entry submission functionality' (#1) from feature/add-submission-functionality into master

Reviewed-on: https://git.godot.foundation/website/showreel-voting/pulls/1
This commit is contained in:
Iñigo Allende
2026-08-04 15:54:08 +00:00
15 changed files with 773 additions and 92 deletions

View File

@@ -19,6 +19,8 @@ services:
image: docker.io/mariadb
volumes:
- mariadb_data:/var/lib/mysql
ports:
- "3306:3306"
environment:
MARIADB_DATABASE: showreel
MARIADB_USER: showreel

View File

@@ -1,7 +1,9 @@
from urllib.parse import urlparse
from flask_wtf import FlaskForm
from wtforms import IntegerField, StringField, ValidationError
from wtforms import IntegerField, SelectField, StringField, ValidationError, EmailField
from wtforms.validators import InputRequired
from gdshowreelvote.database import DB, Showreel, ShowreelStatus
from gdshowreelvote.utils import downvote_video, skip_video, upvote_video
@@ -15,6 +17,13 @@ def validate_action(form, field):
if field.data:
if VOTE_ACTIONS.get(field.data) is None:
raise ValidationError(f"Action '{field.data}' is not supported.")
def validate_urls(form, field):
if field.data:
parsed = urlparse(field.data)
if not all([parsed.scheme, parsed.netloc]):
raise ValidationError(f'Invalid URL: {field.data}')
class CastVoteForm(FlaskForm):
@@ -24,3 +33,39 @@ class CastVoteForm(FlaskForm):
class SelectVideoForm(FlaskForm):
video_id = IntegerField('Video ID', validators=[InputRequired()])
class VideoSubmissionForm(FlaskForm):
game = StringField('Game Title', validators=[InputRequired()])
author_name = StringField('Author Name', validators=[InputRequired()])
contact_email = EmailField('Contact Email', validators=[InputRequired()])
video_link = StringField('Video Link', validators=[InputRequired(), validate_urls]) # TODO: Check specific URL formats
video_download_link = StringField('Video Download Link', validators=[InputRequired(), validate_urls])
follow_me_link = StringField('Follow Me Link', validators=[InputRequired(), validate_urls])
store_link = StringField('Store Link', validators=[InputRequired(), validate_urls])
class ManageShowreelsForm(FlaskForm):
showreel_id = SelectField('Showreel', validators=[InputRequired()], choices=[])
showreel_status = SelectField('Showreel Status', validators=[InputRequired()],
choices=[
(ShowreelStatus.OPENED_TO_SUBMISSIONS.value, ShowreelStatus.OPENED_TO_SUBMISSIONS.value),
(ShowreelStatus.VOTE.value, ShowreelStatus.VOTE.value),
(ShowreelStatus.CLOSED.value, ShowreelStatus.CLOSED.value)
])
def validate(self, extra_validators = None):
if not super().validate(extra_validators):
return False
submissions = DB.session.query(Showreel).filter(Showreel.status == ShowreelStatus.OPENED_TO_SUBMISSIONS).first()
if submissions and self.showreel_status.data == ShowreelStatus.OPENED_TO_SUBMISSIONS.value:
self.showreel_status.errors.append("There is already a showreel open for submissions.")
return False
vote = DB.session.query(Showreel).filter(Showreel.status == ShowreelStatus.VOTE).first()
if vote and self.showreel_status.data == ShowreelStatus.VOTE.value:
self.showreel_status.errors.append("There is already a showreel open for voting.")
return False
return True

View File

@@ -1,22 +1,46 @@
import csv
from io import StringIO
from sqlalchemy import case, func
from flask import (
Blueprint,
Response,
current_app,
g,
redirect,
render_template,
request,
url_for,
)
from sqlalchemy import case, func, or_
from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import NotFound
from flask import Blueprint, Response, current_app, g, redirect, render_template, request, url_for
from gdshowreelvote import auth
from gdshowreelvote.blueprints.forms import VOTE_ACTIONS, CastVoteForm, SelectVideoForm
from gdshowreelvote.database import DB, User, Video, Vote
from gdshowreelvote.utils import choose_random_video, get_total_votes, video_data, vote_data, voting_possible
from gdshowreelvote.blueprints.forms import (
VOTE_ACTIONS,
CastVoteForm,
ManageShowreelsForm,
SelectVideoForm,
VideoSubmissionForm,
)
from gdshowreelvote.database import DB, Showreel, ShowreelStatus, User, Video, Vote
from gdshowreelvote.utils import (
choose_random_video,
extract_steam_app_id,
get_total_votes_for_showreel,
video_data,
vote_data,
voting_possible,
)
bp = Blueprint('votes', __name__)
@bp.route('/')
def home():
content = render_template('home.html', user=g.user)
active_submissions = DB.session.query(Showreel).filter(Showreel.status == ShowreelStatus.OPENED_TO_SUBMISSIONS).first()
active_vote = DB.session.query(Showreel).filter(Showreel.status == ShowreelStatus.VOTE).first()
content = render_template('home.html', user=g.user, active_submissions=active_submissions, active_vote=active_vote)
return render_template('default.html', content = content, user=g.user)
@@ -99,13 +123,13 @@ def history():
return redirect(url_for('votes.home'))
limit = request.args.get('limit')
page = int(request.args.get('page', 1))
total_video_count = DB.session.query(Video).count()
total_user_votes = DB.session.query(Vote).filter(Vote.user_id == g.user.id).count()
total_video_count = DB.session.query(Video).join(Showreel).filter(Showreel.status == ShowreelStatus.VOTE).count()
total_user_votes = DB.session.query(Vote).join(Video).join(Showreel).filter(Showreel.status == ShowreelStatus.VOTE).filter(Vote.user_id == g.user.id).count()
progress = {
'total': total_video_count,
'current': total_user_votes,
}
query = DB.session.query(Vote).filter(Vote.user_id == g.user.id).order_by(Vote.created_at.desc())
query = DB.session.query(Vote).join(Video).join(Showreel).filter(Showreel.status == ShowreelStatus.VOTE).filter(Vote.user_id == g.user.id).order_by(Vote.created_at.desc())
if limit == 'all':
total_results = query.count()
@@ -130,9 +154,19 @@ def history():
@bp.route('/admin')
@auth.admin_required
def admin_view():
total_votes, positive_votes, vote_tally = get_total_votes()
form = ManageShowreelsForm()
showreels = (
DB.session.query(Showreel)
.order_by(
case(
(Showreel.status == ShowreelStatus.VOTE, 0),
(Showreel.status == ShowreelStatus.OPENED_TO_SUBMISSIONS, 1),
else_=2))
.all()
)
form.showreel_id.choices = [(showreel.id, showreel.title) for showreel in showreels]
content = render_template('admin.html', vote_tally=vote_tally, total_votes=total_votes, positive_votes=positive_votes)
content = render_template('admin.html', form=form, showreels=showreels)
if request.args.get('page'):
return content
return render_template('default.html', content = content, user=g.user)
@@ -141,6 +175,10 @@ def admin_view():
@bp.route('/results')
@auth.admin_required
def download_vote_results():
showreel_id = request.args.get("showreel_id", type=int)
showreel = DB.session.get(Showreel, showreel_id)
if showreel is None:
return render_template('error.html', title="Showreel Not Found", message="No showreel ID provided.")
result = (
DB.session.query(
Video,
@@ -151,13 +189,14 @@ def download_vote_results():
)
.outerjoin(Vote, Vote.video_id == Video.id)
.outerjoin(User, User.id == Vote.user_id)
.filter(Video.showreel_id == showreel_id)
.group_by(Video.id)
.order_by(func.coalesce(func.sum(Vote.rating), 0).desc()).all()
)
csv_file = StringIO()
writer = csv.writer(csv_file)
writer.writerow(['Author', 'Follow-me link', 'Game', 'Video link', 'Download link', 'Contact email', 'Store Link', 'Positive votes', 'Negative votes', 'staff', 'fund_member'])
writer.writerow(['Author', 'Follow-me link', 'Game', 'Video link', 'Download link', 'Contact email', 'Store Link', 'Steam App ID', 'Positive votes', 'Negative votes', 'staff', 'fund_member'])
for video, plus_votes, minus_votes, staff_votes, fund_member_votes in result:
writer.writerow([
@@ -168,13 +207,14 @@ def download_vote_results():
video.video_download_link,
video.contact_email,
video.store_link,
extract_steam_app_id(video.store_link),
plus_votes,
minus_votes,
staff_votes,
fund_member_votes
])
response = Response(csv_file.getvalue(), mimetype='text/csv')
response.headers["Content-Disposition"] = "attachment; filename=vote_results.csv"
response.headers["Content-Disposition"] = f"attachment; filename=vote_results_{showreel.title}.csv"
return response
@@ -188,3 +228,159 @@ def video_view(video_id: int):
data = video_data(video)
content = render_template('video-view.html', data=data)
return render_template('default.html', content = content, user=g.user, hide_nav=True)
@bp.route('/submit', methods=['GET'])
def submit():
active_showreel = DB.session.query(Showreel).filter(Showreel.status == ShowreelStatus.OPENED_TO_SUBMISSIONS).count()
if active_showreel != 1:
current_app.logger.warning("No active showreel or multiple active showreels found.")
error_template = render_template('error.html', title="Submissions Closed", message="Submissions are currently closed.")
return render_template('default.html', content = error_template, user=g.user)
form = VideoSubmissionForm()
content = render_template('submit.html', user=g.user, form=form)
return render_template('default.html', content = content, user=g.user)
@bp.route('/submit', methods=['POST'])
@auth.login_required
def post_submit():
form = VideoSubmissionForm()
if not form.validate():
return render_template('submit.html', user=g.user, form=form)
active_showreel = DB.session.query(Showreel).filter(Showreel.status == ShowreelStatus.OPENED_TO_SUBMISSIONS).all()
if not active_showreel or len(active_showreel) > 1:
current_app.logger.warning("No active showreel or multiple active showreels found.")
return render_template('error.html', title="Submissions Closed", message="Submissions are currently closed.")
duplicate_video = DB.session.query(Video).filter(or_(Video.video_link == form.video_link.data, Video.video_download_link == form.video_download_link.data)).first()
if duplicate_video:
form.video_link.errors.append('A video with the same link or download link has already been submitted.')
return render_template('submit.html', user=g.user, form=form)
active_showreel = active_showreel[0]
new_video = Video(
game=form.game.data,
author_name=form.author_name.data,
contact_email=form.contact_email.data,
video_link=form.video_link.data,
video_download_link=form.video_download_link.data,
follow_me_link=form.follow_me_link.data,
store_link=form.store_link.data,
author=g.user,
showreel=active_showreel
)
try:
DB.session.add(new_video)
DB.session.commit()
except IntegrityError as e:
current_app.logger.error(f"Database integrity error while submitting video: {e}")
DB.session.rollback()
return render_template('submit.html', user=g.user, form=form)
return render_template('home.html', user=g.user, active_submissions=True, submission_success=True) # TODO: Add flag to show submission success message
@bp.route('/showreel/update-status', methods=['POST'])
@auth.admin_required
def update_showreel_status():
form = ManageShowreelsForm()
showreels = DB.session.query(Showreel).all()
form.showreel_id.choices = [(showreel.id, showreel.title) for showreel in showreels]
if not form.validate():
return redirect(url_for('votes.admin_view'))
showreel = DB.session.query(Showreel).filter(Showreel.id == form.showreel_id.data).first()
if not showreel:
return render_template('error.html', title="Showreel Not Found", message="The requested showreel was not found.")
showreel.status = form.showreel_status.data
DB.session.commit()
return redirect(url_for('votes.admin_view'))
@bp.route('/user/submissions', methods=['GET'])
@auth.login_required
def user_submissions():
open_showreel = DB.session.query(Showreel).filter(Showreel.status == ShowreelStatus.OPENED_TO_SUBMISSIONS).first()
open_submissions = DB.session.query(Video).filter(Video.author_id == g.user.id).filter(Video.showreel == open_showreel).all()
closed_submissions = DB.session.query(Video).filter(Video.author_id == g.user.id).filter(Video.showreel != open_showreel).all()
content = render_template('user-submissions.html', user=g.user, open_submissions=open_submissions, closed_submissions=closed_submissions, open_showreel=open_showreel)
if request.args.get('update'):
return content
return render_template('default.html', content = content, user=g.user)
@bp.route('/user/submissions/<int:video_id>/manage', methods=['GET'])
@auth.login_required
def manage_submission(video_id: int):
video = DB.session.query(Video).filter(Video.id == video_id).filter(Video.author == g.user).first()
if not video:
return render_template('error.html', title="Video Not Found", message="The requested video submission was not found.")
if video.showreel.status != ShowreelStatus.OPENED_TO_SUBMISSIONS:
return render_template('error.html', title="Cannot Manage Submission", message="Submissions can only be managed while the showreel is open to submissions.")
form = VideoSubmissionForm(obj=video)
content = render_template('manage-submission.html', user=g.user, form=form, video_id=video.id)
return render_template('default.html', content = content, user=g.user)
@bp.route('/user/submissions/<int:video_id>/delete', methods=['POST'])
@auth.login_required
def delete_submission(video_id: int):
video = DB.session.query(Video).filter(Video.id == video_id).filter(Video.author == g.user).first()
if not video:
return render_template('error.html', title="Video Not Found", message="The requested video submission was not found.")
if video.showreel.status != ShowreelStatus.OPENED_TO_SUBMISSIONS:
return render_template('error.html', title="Cannot Delete Submission", message="Submissions can only be deleted while the showreel is open to submissions.")
DB.session.delete(video)
DB.session.commit()
return redirect(url_for('votes.user_submissions'))
@bp.route('/user/submissions/<int:video_id>/update', methods=['POST'])
@auth.login_required
def update_submission(video_id: int):
video = DB.session.query(Video).filter(Video.id == video_id).filter(Video.author == g.user).first()
if not video:
return render_template('error.html', title="Video Not Found", message="The requested video submission was not found.")
if video.showreel.status != ShowreelStatus.OPENED_TO_SUBMISSIONS:
return render_template('error.html', title="Cannot Update Submission", message="Submissions can only be updated while the showreel is open to submissions.")
form = VideoSubmissionForm()
if not form.validate():
return render_template('update-submissions.html', user=g.user, submissions=[video], open_showreel=video.showreel, form=form)
video.game = form.game.data
video.author_name = form.author_name.data
video.contact_email = form.contact_email.data
video.video_link = form.video_link.data
video.video_download_link = form.video_download_link.data
video.follow_me_link = form.follow_me_link.data
video.store_link = form.store_link.data
DB.session.commit()
return redirect(url_for('votes.user_submissions', update='1'))
@bp.route("/showreel-results")
@auth.admin_required
def showreel_results():
showreel_id = request.args.get("showreel_id", type=int)
showreel = DB.session.get(Showreel, showreel_id)
if not showreel:
return render_template('error.html', title="Showreel Not Found", message="The requested showreel was not found.")
vote_metrics = get_total_votes_for_showreel(showreel)
return render_template("partials/vote-results.html", metrics=vote_metrics, showreel=showreel)

View File

@@ -4,7 +4,7 @@ from typing import List, Optional
from urllib.parse import parse_qs, urlparse
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, MetaData, String, Table
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, MetaData, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -24,7 +24,7 @@ migrate = Migrate()
class ShowreelStatus(enum.Enum):
OPENED_TO_SUBMISSIONS = 'OPEN'
OPENED_TO_SUBMISSIONS = 'OPENED_TO_SUBMISSIONS'
VOTE = 'VOTE'
CLOSED = 'CLOSED'

View File

@@ -1,7 +1,5 @@
from urllib.parse import parse_qs, urlparse
from werkzeug.exceptions import NotFound
from typing import Dict, List, Optional, Tuple
from typing import Any, Dict, List, Tuple
from urllib.parse import urlparse
from sqlalchemy import and_, func
from gdshowreelvote.database import DB, Showreel, ShowreelStatus, User, Video, Vote
@@ -88,8 +86,8 @@ def video_data(video: Video) -> Dict:
def vote_data(user: User, video: Video) -> Tuple[Dict, Dict]:
total_video_count = DB.session.query(Video).count()
total_user_votes = DB.session.query(Vote).filter(Vote.user_id == user.id).count()
total_video_count = DB.session.query(Video).join(Showreel).filter(Showreel.status == ShowreelStatus.VOTE).count()
total_user_votes = DB.session.query(Vote).join(Video).join(Showreel).filter(Showreel.status == ShowreelStatus.VOTE).filter(Vote.user_id == user.id).count()
data = video_data(video) if video else None
@@ -101,9 +99,11 @@ def vote_data(user: User, video: Video) -> Tuple[Dict, Dict]:
return data, progress
def get_total_votes() -> Tuple[int, int, List[Tuple[Video, int, int]]]:
total_votes = DB.session.query(func.count(Vote.id)).filter(Vote.rating != 0).scalar()
positive_votes = DB.session.query(func.count(Vote.id)).filter(Vote.rating == 1).scalar()
def get_total_votes_for_showreel(showreel: Showreel|None) -> Dict[str, Any]:
selected_showreel = showreel if showreel else DB.session.query(Showreel).first()
output = {}
output['total_votes'] = DB.session.query(func.count(Vote.id)).join(Video).filter(Video.showreel == selected_showreel).filter(Vote.rating != 0).scalar()
output['positive_votes'] = DB.session.query(func.count(Vote.id)).join(Video).filter(Video.showreel == selected_showreel).filter(Vote.rating == 1).scalar()
results = (
DB.session.query(
Video,
@@ -111,13 +111,43 @@ def get_total_votes() -> Tuple[int, int, List[Tuple[Video, int, int]]]:
func.count(Vote.id).label("vote_count"),
)
.outerjoin(Vote, Vote.video_id == Video.id)
.filter(Video.showreel == selected_showreel)
.group_by(Video.id)
.order_by(func.coalesce(func.sum(Vote.rating), 0).desc())
.all()
)
output['results'] = []
for video, vote_sum, vote_count in results:
output['results'].append(
{
"id": video.id,
"game": video.game,
"author_name": video.author_name,
"video_link": video.video_link,
"vote_sum": vote_sum,
"vote_count": vote_count,
}
)
return total_votes, positive_votes, results
return output
def voting_possible() -> bool:
return DB.session.query(Showreel).where(Showreel.status==ShowreelStatus.VOTE).count() > 0
def extract_steam_app_id(url: str) -> str:
""" Extract the Steam App ID from a given URL. """
if not url:
return ""
parsed = urlparse(url)
if parsed.netloc != 'store.steampowered.com':
return ""
path_parts = parsed.path.strip('/').split('/')
if len(path_parts) < 2 or path_parts[0] != 'app':
return ""
app_id = path_parts[1]
if not app_id.isdigit():
return ""
return app_id

11
main.py
View File

@@ -61,6 +61,17 @@ def create_app(config=None):
# Commands
# ------------------------------------------------
@app.cli.command('create-showreel')
@click.argument("name")
def create_showreel(name):
if not name:
print('Showreel name is required.')
return
showreel = Showreel(status=ShowreelStatus.CLOSED, title=name)
DB.session.add(showreel)
DB.session.commit()
print(f'Created showreel: {name} (ID: {showreel.id})')
@app.cli.command('create-sample-data')
def create_sample_data():
if current_app.config['ENV'] != 'dev':

View File

@@ -237,3 +237,11 @@ textarea {
}
}
}
p.error {
color: #f65994;
}
p.info {
opacity: 0.6;
}

View File

@@ -13,27 +13,68 @@
}
</style>
<main id="admin">
<h1 style="margin-bottom: 20px;">Vote results</h1>
<div>
<p>Total votes: <strong>{{ total_votes }}</strong></p>
<p>Positive votes: <strong>{{ positive_votes }}</strong></p>
<p>Negative votes: <strong>{{ total_votes - positive_votes }}</strong></p>
</div>
<br>
<form action="{{ url_for('votes.download_vote_results') }}" method="get" style="display:inline;">
<button type="submit" class="button primary small">Download results .csv</button>
</form>
<div>
<div class="entries">
{% for entry in vote_tally %}
<div class="entry panel padded">
<h2><a href="{{ url_for('votes.video_view', video_id=entry[0].id) }}">{{ entry[0].game }}</a></h2>
<p>Category: <strong>{{ entry[0].showreel.title }}</strong></p>
<p>Author: <strong>{{ entry[0].author_name }}</strong></p>
<p>Vote sum: <strong>{{ entry[1] }}</strong></p>
<p>Votes received: <strong>{{ entry[2] }}</strong></p>
</div>
{% endfor %}
<h1>Manage Shorwreels</h1>
<form action="{{ url_for('votes.update_showreel_status') }}" method="post" style="display:inline;">
{{ form.csrf_token }}
{{ form.showreel_id.label }}
<select id="showreel_id" name="showreel_id">
{% for showreel in showreels %}
<option value="{{ showreel.id }}"
data-status="{{ showreel.status.name }}">
{{ showreel.title }}
</option>
{% endfor %}
</select>
{{ form.showreel_status.label }}
{{ form.showreel_status(id="showreel_status") }}
<button type="submit" class="button primary small">Save</button>
</form>
<h1 style="margin-bottom: 20px;">Vote results</h1>
<form action="{{ url_for('votes.download_vote_results') }}" method="get" style="display:inline;">
<input type="hidden" id="download-showreel-id" name="showreel_id" value="">
<button type="submit" class="button primary small">Download results .csv</button>
</form>
<div id="vote-results">
</div>
</div>
</main>
</main>
<script>
document.addEventListener('DOMContentLoaded', function () {
const showreelSelect = document.getElementById('showreel_id');
const statusSelect = document.getElementById('showreel_status');
const voteResults = document.getElementById('vote-results');
const downloadShowreelId = document.getElementById('download-showreel-id');
function updateStatus() {
const selectedOption = showreelSelect.options[showreelSelect.selectedIndex];
const status = selectedOption.dataset.status;
if (!status) return;
statusSelect.value = status;
downloadShowreelId.value = showreelSelect.value;
}
function updateResults() {
const showreelId = showreelSelect.value;
fetch(`/showreel-results?showreel_id=${showreelId}`)
.then(response => response.text())
.then(html => {
voteResults.innerHTML = html;
});
}
showreelSelect.addEventListener('change', function () {
updateStatus();
updateResults();
});
updateStatus();
updateResults();
});
</script>

4
templates/error.html Normal file
View File

@@ -0,0 +1,4 @@
<main>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
</main>

View File

@@ -20,53 +20,73 @@
}
</style>
<main>
<div class="panel padded">
{% comment %}
<!-- During the voting period -->
{% if user %}
{% if user.can_vote() %}
<p>Cast your votes until <strong>October 20th</strong> 📅</p>
<ul>
<li>Videos are presented in random order, and scores are hidden until the voting period ends</li>
<li>Your votes are anonymous</li>
<li>You can change your votes until the voting period ends</li>
<li>We encourage you to vote for as many entries as you can</li>
<li>If you found any issue or if you have a suggestion, please create an issue in our <a href="https://github.com/godotengine/godot-showreel-voting/issues">issue tracker</a></li>
</ul>
<p style="margin: 20px 0;">Happy voting!</p>
<a class="button primary" id="vote-link" href="/vote">Start Voting</a> <a class="button dim" href="/history">History</a>
{% if active_submissions %}
<div class="panel padded" style="margin-top: 20px;">
<!-- During the submission period -->
{% if user %}
{% if submission_success %}
<h2>Thank you for your submission!</h2>
<p>Your entry has been received, voting will start on <strong>?????</strong> 📅</p>
{% else %}
<p>Submit your game for the Godot showreel until <strong>?????</strong> 📅</p>
{% endif %}
<a class="button primary" href="/submit">Submit entry</a>
{% else %}
<p>Only Godot Engine maintainers or members of the Development Fund can vote.</p>
<p>If you would like to participate in the process, make sure to sign up to <a href="https://fund.godotengine.org">the Development Fund</a>.</p>
<p style="margin-top: 20px;">
<a href="https://fund.godotengine.org" class="button">Sign Up</a>
</p>
<p style="margin-top: 20px;">
<strong>Already a fund member?</strong><br>
If you became a member recently, try to <a href="{{ url_for('oidc.logout') }}">logout</a> and then log in again. Also make sure that you use the same account as you used on the fund page!
</p>
You need to be logged in to submit an entry:
<a href="/login" class="button dim small">Log In</a>
{% endif %}
</div>
{% elif active_vote %}
<div class="panel padded" style="margin-top: 20px;">
<!-- During the voting period -->
{% if user %}
{% if user.can_vote() %}
<p>Cast your votes until <strong>October 20th</strong> 📅</p>
<ul>
<li>Videos are presented in random order, and scores are hidden until the voting period ends</li>
<li>Your votes are anonymous</li>
<li>You can change your votes until the voting period ends</li>
<li>We encourage you to vote for as many entries as you can</li>
<li>If you found any issue or if you have a suggestion, please create an issue in our <a href="https://github.com/godotengine/godot-showreel-voting/issues">issue tracker</a></li>
</ul>
<p style="margin: 20px 0;">Happy voting!</p>
<a class="button primary" id="vote-link" href="/vote">Start Voting</a> <a class="button dim" href="/history">History</a>
{% else %}
<p>Only Godot Engine maintainers or members of the Development Fund can vote.</p>
<p>If you would like to participate in the process, make sure to sign up to <a href="https://fund.godotengine.org">the Development Fund</a>.</p>
<p style="margin-top: 20px;">
<a href="https://fund.godotengine.org" class="button">Sign Up</a>
</p>
<p style="margin-top: 20px;">
<strong>Already a fund member?</strong><br>
If you became a member recently, try to <a href="{{ url_for('oidc.logout') }}">logout</a> and then log in again. Also make sure that you use the same account as you used on the fund page!
</p>
{% endif %}
{% else %}
<hr>
You need to be logged in to vote:
<a href="/login" class="button dim small">Log In</a>
{% endif %}
{% else %}
<hr>
You need to be logged in to vote:
<a href="/login" class="button dim small">Log In</a>
{% endif %}
{% endcomment %}
<h1>Welcome!</h1>
<p>The voting period for this years showreel videos has finished.</p>
<p style="margin: 20px 0;">Thank you to everyone who participated!</p>
</div>
<h1>Welcome!</h1>
<p>The voting period for this years showreel videos has finished.</p>
<p style="margin: 20px 0;">Thank you to everyone who participated!</p>
</div>
{% else %}
<div class="panel padded" style="margin-top: 20px;">
<p>No showreels just yet!</p>
</div>
{% endif %}
<div class="panel padded" style="margin-top: 20px;">
<h2>Previous editions:<span><a href="https://www.youtube.com/playlist?list=PLeG_dAglpVo6EpaO9A1nkwJZOwrfiLdQ8">(View all)</a></span></h2>
<div class="grid-2" style="margin-top: 10px;">
<iframe width="560" height="315" src="https://www.youtube.com/embed/n1Lon_Q2T18" frameborder="0" allowfullscreen="" style="width:100%;aspect-ratio:16/9;height:auto; border-radius: 11px;"></iframe>
<iframe width="560" height="315" src="https://www.youtube.com/embed/W1_zKxYEP6Q" frameborder="0" allowfullscreen="" style="width:100%;aspect-ratio:16/9;height:auto; border-radius: 11px;"></iframe>
<div class="panel padded" style="margin-top: 20px;">
<h2>Previous editions:<span><a href="https://www.youtube.com/playlist?list=PLeG_dAglpVo6EpaO9A1nkwJZOwrfiLdQ8">(View all)</a></span></h2>
<div class="grid-2" style="margin-top: 10px;">
<iframe width="560" height="315" src="https://www.youtube.com/embed/n1Lon_Q2T18" frameborder="0" allowfullscreen="" style="width:100%;aspect-ratio:16/9;height:auto; border-radius: 11px;"></iframe>
<iframe width="560" height="315" src="https://www.youtube.com/embed/W1_zKxYEP6Q" frameborder="0" allowfullscreen="" style="width:100%;aspect-ratio:16/9;height:auto; border-radius: 11px;"></iframe>
</div>
</div>
</div>
</main>

View File

@@ -16,6 +16,9 @@
</a>
<div id="dropdown-menu" class="dropdown-content">
<a href="/about">About</a>
{% if user %}
<a href="/user/submissions">Submissions</a>
{% endif %}
{% if user.can_vote() %}
<a href="/vote">Vote</a>
<a href="/history">History</a>

View File

@@ -0,0 +1,101 @@
<main id="submission-form">
<div>
<h1>Showreel Submission</h1>
<p>Fill in the form to submit your video for the Godot Showreel.</p>
</div>
<hr>
<form method="POST" enctype="multipart/form-data" hx-post="{{ url_for('votes.update_submission', video_id=video_id, _method='POST') }}" hx-target='#submission-form' hx-swap='outerHTML'>
{{ form.csrf_token }}
{{ form.game.label }}<br>
{{ form.game(size=100) }}<br>
{% for error in form.game.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.author_name.label }}<br>
{{ form.author_name(size=100) }}<br>
{% for error in form.author_name.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.contact_email.label }}<br>
{{ form.contact_email(size=100) }}<br>
{% for error in form.contact_email.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.video_link.label }}<br>
{{ form.video_link(size=100) }}
<button type="button" class="button secondary" id="preview-btn">Preview</button><br>
{% for error in form.video_link.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
<div id="preview-container" style="display: none;">
<iframe width="560" height="315" src="" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
</div>
<br>
{{ form.video_download_link.label }}<br>
{{ form.video_download_link(size=100) }}<br>
{% for error in form.video_download_link.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.follow_me_link.label }}<br>
{{ form.follow_me_link(size=100) }}<br>
{% for error in form.follow_me_link.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.store_link.label }}<br>
{{ form.store_link(size=100) }}<br>
{% for error in form.store_link.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
<button type="submit" class="button secondary">Update</button>
</form>
</main>
<script>
// Render video preview
function parseVideoID(videoLink) {
const videoUrl = new URL(videoLink);
console.log(videoUrl.hostname);
const hostname = videoUrl.hostname
if (hostname == 'youtu.be') {
return videoUrl.pathname.slice(1);
}
else if (hostname === 'www.youtube.com' || hostname === 'youtube.com' || hostname === 'm.youtube.com') {
if (videoUrl.pathname == '/watch') {
return videoUrl.searchParams.get('v');
}
else if (videoUrl.pathname.startsWith('/embed/')) {
return videoUrl.pathname.split('/')[2];
}
else if (videoUrl.pathname.startsWith('/v/')) {
return videoUrl.pathname.split('/')[2];
}
}
return null;
}
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('preview-btn').addEventListener('click', function() {
const originalForm = document.querySelector("#submission-form form");
const previewContainer = document.getElementById('preview-container');
let videoID = parseVideoID(originalForm.querySelector("input[name='video_link']").value);
if (!videoID) {
previewContainer.innerHTML = '<p class="error">Invalid video link provided.</p>';
previewContainer.style.display = 'block';
return;
}
videoID = 'https://www.youtube.com/embed/' + videoID + '?rel=0&autoplay=1';
previewContainer.querySelector('iframe').src = videoID;
previewContainer.style.display = 'block';
});
})
</script>

View File

@@ -0,0 +1,28 @@
{% if metrics.total_votes == 0 %}
<p>No voting data available for this showreel yet.</p>
{% else %}
<div>
<p>Total votes: <strong>{{ metrics.total_votes }}</strong></p>
<p>Positive votes: <strong>{{ metrics.positive_votes }}</strong></p>
<p>Negative votes: <strong>{{ metrics.total_votes - metrics.positive_votes }}</strong></p>
</div>
<br>
{% endif %}
<div>
<div class="entries">
{% for entry in metrics.results %}
<div class="entry panel padded">
<h2>
<a href="{{ url_for('votes.video_view', video_id=entry.id) }}">
{{ entry.game }}
</a>
</h2>
<p>Category: <strong>{{ showreel.title }}</strong></p>
<p>Author: <strong>{{ entry.author_name }}</strong></p>
<p>Vote sum: <strong>{{ entry.vote_sum }}</strong></p>
<p>Votes received: <strong>{{ entry.vote_count }}</strong></p>
</div>
{% endfor %}
</div>
</div>

103
templates/submit.html Normal file
View File

@@ -0,0 +1,103 @@
<main id="submission-form">
<div>
<h1>Showreel Submission</h1>
<p>Fill in the form to submit your video for the Godot Showreel.</p>
</div>
<hr>
<form method="POST" enctype="multipart/form-data" hx-post="{{ url_for("votes.post_submit", _method='POST') }}" hx-target='#submission-form' hx-swap='outerHTML'>
{{ form.csrf_token }}
{{ form.game.label }}<br>
{{ form.game(size=100) }}<br>
{% for error in form.game.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.author_name.label }}<br>
{{ form.author_name(size=100) }}<br>
{% for error in form.author_name.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.contact_email.label }}<br>
{{ form.contact_email(size=100) }}<br>
{% for error in form.contact_email.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.video_link.label }}<br>
{{ form.video_link(size=100) }}
<button type="button" class="button secondary" id="preview-btn">Preview</button><br>
{% for error in form.video_link.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
<div id="preview-container" style="display: none;">
<iframe width="560" height="315" src="" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
</div>
<br>
{{ form.video_download_link.label }}<br>
{{ form.video_download_link(size=100) }}<br>
{% for error in form.video_download_link.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.follow_me_link.label }}<br>
{{ form.follow_me_link(size=100) }}<br>
{% for error in form.follow_me_link.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
{{ form.store_link.label }}
<p class="info">We will use this to promote your game in future events and showcase pages.</p>
{{ form.store_link(size=100) }}<br>
{% for error in form.store_link.errors %}
<p class="error">{{ error }}</p>
{% endfor %}<br>
<button type="submit" class="button secondary">Submit</button>
</form>
</main>
<script>
// Render video preview
function parseVideoID(videoLink) {
const videoUrl = new URL(videoLink);
console.log(videoUrl.hostname);
const hostname = videoUrl.hostname
if (hostname == 'youtu.be') {
return videoUrl.pathname.slice(1);
}
else if (hostname === 'www.youtube.com' || hostname === 'youtube.com' || hostname === 'm.youtube.com') {
if (videoUrl.pathname == '/watch') {
return videoUrl.searchParams.get('v');
}
else if (videoUrl.pathname.startsWith('/embed/')) {
return videoUrl.pathname.split('/')[2];
}
else if (videoUrl.pathname.startsWith('/v/')) {
return videoUrl.pathname.split('/')[2];
}
}
return null;
}
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('preview-btn').addEventListener('click', function() {
const originalForm = document.querySelector("#submission-form form");
const previewContainer = document.getElementById('preview-container');
let videoID = parseVideoID(originalForm.querySelector("input[name='video_link']").value);
if (!videoID) {
previewContainer.innerHTML = '<p class="error">Invalid video link provided.</p>';
previewContainer.style.display = 'block';
return;
}
videoID = 'https://www.youtube.com/embed/' + videoID + '?rel=0&autoplay=1';
previewContainer.querySelector('iframe').src = videoID;
previewContainer.style.display = 'block';
});
})
</script>

View File

@@ -0,0 +1,89 @@
<style>
.entry {
.info {
display: grid;
margin-bottom: 5px;
gap: 10px;
@media (min-width: 768px) {
grid-template-columns: 120px 1fr;
}
.category {
font-size: 12px;
margin: 4px 0px;
}
}
.image-container {
border-radius: 6px;
overflow: hidden;
img {
max-width: 100%;
margin-bottom: -15px;
margin-top: -11px;
width: auto;
aspect-ratio: 16/9;
}
}
}
</style>
<main id="user-submissions">
<h1>User Submissions</h1>
<hr>
{% if open_submissions %}
<h2>Open Submissions</h2>
<div>
{% for entry in open_submissions %}
<div class="entry panel padded">
<div class="info">
<div>
<div class="image-container">
<img src="https://img.youtube.com/vi/{{ entry.parse_youtube_video_id() }}/default.jpg" alt="Video thumbnail" loading="lazy">
</div>
</div>
<div>
<h2>{{ entry.game }}</h2>
<p><strong>{{ entry.author_name }}</strong></p>
<p class="category">{{ entry.showreel.title }}</p>
</div>
</div>
<div>
<a href="{{url_for('votes.manage_submission', video_id=entry.id) }}" style="text-decoration: none;">
<form action="{{ url_for('votes.manage_submission', video_id=entry.id) }}" method="get" style="display:inline-block; margin-top: 10px;">
<button type="submit" class="button secondary">Edit entry</button>
</form>
</a>
<form action="{{ url_for('votes.delete_submission', video_id=entry.id) }}" method="post" style="display:inline;" onsubmit="return confirm('Are you sure you want to delete this entry?');">
<button type="submit" class="button tertiary">Delete entry</button>
</form>
</div>
</div>
{% endfor %}
</div>
<hr>
{% endif %}
{% if closed_submissions %}
<h2>Closed Submissions</h2>
<div>
{% for entry in closed_submissions %}
<div class="entry panel padded">
<div class="info">
<div>
<div class="image-container">
<img src="https://img.youtube.com/vi/{{ entry.parse_youtube_video_id() }}/default.jpg" alt="Video thumbnail" loading="lazy">
</div>
</div>
<div>
<h2>{{ entry.game }}</h2>
<p><strong>{{ entry.author_name }}</strong></p>
<p class="category">{{ entry.showreel.title }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if not open_submissions and not closed_submissions %}
<p>You haven't submitted any entries yet.</p>
{% endif %}
</main>