refactor nfg

modified:   app.py
	new file:   templates/admin_dashboard.html.j2
	new file:   templates/base.html.j2
	new file:   templates/daily_leaderboard.html.j2
	new file:   templates/daily_puzzle.html.j2
	new file:   templates/home.html.j2
	new file:   templates/invite_admin.html.j2
	new file:   templates/king_of_the_mountain.html.j2
	new file:   templates/leaderboard_index.html.j2
	new file:   templates/login.html.j2
	new file:   templates/manage_admins.html.j2
	new file:   templates/profile.html.j2
	new file:   templates/puzzle_menu.html.j2
	new file:   templates/upload.html.j2
	new file:   templates/verify.html.j2
This commit is contained in:
Russell Ballestrini 2024-12-27 12:17:52 -05:00
parent cf84aa01b3
commit e13b5ef5fa
15 changed files with 1101 additions and 903 deletions

View file

@ -0,0 +1,34 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>Admin Dashboard</h1>
<p>Welcome, {{ request.user.username }}! Manage puzzles and admins here.</p>
<h2>Puzzle Management</h2>
<p><a href="{{ request.route_url('upload') }}">Upload/Schedule Puzzles</a></p>
<h2>Admin Management</h2>
<p><a href="{{ request.route_url('invite_admin') }}">Invite Admin</a></p>
<p><a href="{{ request.route_url('manage_admins') }}">Manage Admins</a></p>
<h2>Scheduled Puzzles</h2>
<table>
<tr>
<th>Date</th>
<th>Size</th>
<th>Title</th>
<th>Actions</th>
</tr>
{% for p in puzzles %}
<tr>
<td>{{ p.date }}</td>
<td>{{ p.size }}x{{ p.size }}</td>
<td>{{ p.title }}</td>
<td>
<a href="{{ request.route_url('edit_puzzle', puzzle_id=p.id) }}">Edit</a>
{% if p.is_visible %}
<a href="{{ request.route_url('toggle_visibility', puzzle_id=p.id) }}">Hide</a>
{% else %}
<a href="{{ request.route_url('toggle_visibility', puzzle_id=p.id) }}">Show</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

49
templates/base.html.j2 Normal file
View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My Puzzle Game{% endblock %}</title>
<link href="https://unpkg.com/@picocss/pico@1.*/css/pico.min.css" rel="stylesheet">
<style>
/* Custom styles */
body { margin: 0; padding: 0; }
nav ul { list-style-type: none; padding: 0; display: flex; flex-wrap: wrap; }
nav ul li { margin-right: 10px; }
nav ul li a { text-decoration: none; }
@media (max-width: 600px) {
nav ul { flex-direction: column; }
nav ul li { margin-bottom: 10px; }
}
</style>
</head>
<body>
<nav>
<ul>
{% if request.user and request.user.is_admin %}
<li><a href="{{ request.route_url('admin_dashboard') }}">Admin Dashboard</a></li>
{% endif %}
<li><a href="{{ request.route_url('home') }}">Home</a></li>
<li><a href="{{ request.route_url('puzzle_menu') }}">Today's Puzzles</a></li>
<li><a href="{{ request.route_url('leaderboard_index') }}">Leaderboards</a></li>
<li><a href="{{ request.route_url('king_of_the_mountain') }}">King of the Mountain</a></li>
{% if request.user %}
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li><a href="{{ request.route_url('logout') }}">Logout</a></li>
{% else %}
<li><a href="{{ request.route_url('login') }}">Login</a></li>
{% endif %}
</ul>
</nav>
{% if request.session.peek_flash() %}
{% for message in request.session.pop_flash() %}
<div class="alert">{{ message }}</div>
{% endfor %}
{% endif %}
<main class="container">
{% block content %}
<!-- Default content can go here, or leave blank -->
{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,30 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>Leaderboard for {{ puzzle.title }} ({{ puzzle.date }})</h1>
<p>
<strong>Sort by:</strong>
<a href="{{ request.route_url('daily_leaderboard', puzzle_id=puzzle.id, sort='time') }}">Top Speed</a> |
<a href="{{ request.route_url('daily_leaderboard', puzzle_id=puzzle.id, sort='moves') }}">Least Moves</a>
</p>
<p>Players are ranked based on their total time and moves. Faster times and fewer moves result in better rankings.</p>
<table>
<tr>
<th>Rank</th>
<th>Username/Guest</th>
<th>Moves</th>
<th>Rotations</th>
<th>Total Actions</th>
<th>Time (sec)</th>
</tr>
{% for row in attempts %}
<tr>
<td>{{ loop.index }}</td>
<td><a href="{{ request.route_url('view_profile', user_id=row.user_id) }}">{{ row.user_display_name }}</a></td>
<td>{{ row.move_count }}</td>
<td>{{ row.rotation_count }}</td>
<td>{{ row.move_count + row.rotation_count }}</td>
<td>{{ "%.2f"|format((row.time_completed - row.time_started).total_seconds() if row.time_completed else 0) }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View file

@ -0,0 +1,344 @@
{% extends 'base.html.j2' %}
{% block content %}
{% if puzzle %}
<h1>{{ puzzle.title }}</h1>
<p>Date: {{ puzzle.date }}</p>
<p>
Moves: <span id="moveCount">{{ move_count }}</span><br>
Rotations: <span id="rotationCount">{{ rotation_count }}</span><br>
</p>
<div id="puzzle-container"></div>
<button id="downloadButton" style="display:none;">Download Image</button>
<!-- Win Modal -->
<div id="winModal" class="modal">
<div class="modal-content">
<span id="winModalClose" class="close">&times;</span>
<h2>Congratulations!</h2>
<p>You solved the puzzle!</p>
{% if user_rank is not none %}
<p>Your Rank: {{ user_rank }}</p>
{% else %}
<p>Your rank is not available.</p>
{% endif %}
<button id="modalDownloadButton">Download Image</button>
</div>
</div>
<!-- Styles -->
<style>
#puzzle-container {
position: relative;
width: 100%;
max-width: 400px;
margin: 0 auto;
padding-bottom: 100%; /* Maintain square aspect ratio */
border: 1px solid #ccc;
background-color: #eee;
}
.puzzle-piece {
position: absolute;
background-image: url('{{ puzzle_image_url }}');
background-size: {{ puzzle.size * 100 }}%;
background-repeat: no-repeat;
cursor: pointer;
box-sizing: border-box;
}
.puzzle-piece.selected {
outline: 2px solid red;
}
.modal {
display: none; /* Hidden by default */
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.5); /* Black with opacity */
}
.modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 400px;
text-align: center;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover,
.close:focus {
color: black;
}
.confetti {
position: fixed;
width: 10px;
height: 10px;
top: 0;
animation: confetti-fall 3s linear infinite;
}
@keyframes confetti-fall {
0% {transform: translateY(0);}
100% {transform: translateY(100vh);}
}
</style>
<!-- Scripts -->
<script>
document.addEventListener("DOMContentLoaded", function() {
let pieces = {{ attempt_state_json | safe }};
const gridSize = {{ puzzle.size }};
let isSolved = {{ 'true' if attempt_is_solved else 'false' }};
let moveCount = {{ move_count }};
let rotationCount = {{ rotation_count }};
const puzzleContainer = document.getElementById("puzzle-container");
const moveCountDisplay = document.getElementById("moveCount");
const rotationCountDisplay = document.getElementById("rotationCount");
const downloadButton = document.getElementById("downloadButton");
const winModal = document.getElementById("winModal");
const winModalClose = document.getElementById("winModalClose");
const modalDownloadButton = document.getElementById("modalDownloadButton");
// Map of UUID to piece data for quick lookup
let pieceMap = {};
pieces.forEach(piece => {
pieceMap[piece.uuid] = piece;
});
// Initialize the game board
function initBoard() {
puzzleContainer.innerHTML = '';
// Build the board
pieces.forEach(piece => {
const pieceElement = document.createElement("div");
pieceElement.classList.add("puzzle-piece");
pieceElement.dataset.pieceUuid = piece.uuid;
pieceElement.dataset.currentIndex = piece.currentIndex;
pieceElement.dataset.rotation = piece.rotation;
// Set size and position
const pieceSizePercent = 100 / gridSize;
pieceElement.style.width = `${pieceSizePercent}%`;
pieceElement.style.height = `${pieceSizePercent}%`;
const currentRow = Math.floor(piece.currentIndex / gridSize);
const currentCol = piece.currentIndex % gridSize;
pieceElement.style.left = `${currentCol * pieceSizePercent}%`;
pieceElement.style.top = `${currentRow * pieceSizePercent}%`;
// Set background position
const correctRow = Math.floor(piece.correctIndex / gridSize);
const correctCol = piece.correctIndex % gridSize;
pieceElement.style.backgroundPosition = `${-correctCol * pieceSizePercent}% ${-correctRow * pieceSizePercent}%`;
// Set rotation
pieceElement.style.transform = `rotate(${piece.rotation}deg)`;
if (!isSolved) {
// Click to rotate
pieceElement.addEventListener("click", function(event) {
rotatePiece(piece.uuid);
});
// Drag events for swapping
pieceElement.draggable = true;
pieceElement.addEventListener("dragstart", dragStart);
pieceElement.addEventListener("dragover", dragOver);
pieceElement.addEventListener("drop", drop);
}
puzzleContainer.appendChild(pieceElement);
});
}
let draggedPieceUuid = null;
function dragStart(event) {
if (isSolved) return;
draggedPieceUuid = event.target.dataset.pieceUuid;
event.dataTransfer.setData("text/plain", "");
}
function dragOver(event) {
event.preventDefault();
}
function drop(event) {
event.preventDefault();
if (isSolved || !draggedPieceUuid) return;
const dropTarget = event.target;
const targetPieceUuid = dropTarget.dataset.pieceUuid;
if (targetPieceUuid && targetPieceUuid !== draggedPieceUuid) {
// Swap pieces
swapPieces(draggedPieceUuid, targetPieceUuid);
moveCount++;
updateScores();
recordAction('swap', {
pieceUUIDA: draggedPieceUuid,
pieceUUIDB: targetPieceUuid
});
}
draggedPieceUuid = null;
}
function rotatePiece(pieceUuid) {
if (isSolved) return;
let piece = pieceMap[pieceUuid];
piece.rotation = (piece.rotation + 90) % 360;
updatePieceRotationInDOM(piece);
rotationCount++;
updateScores();
recordAction('rotate', { pieceUUID: pieceUuid, rotation: piece.rotation });
}
function updatePieceRotationInDOM(piece) {
const pieceElement = document.querySelector(`[data-piece-uuid="${piece.uuid}"]`);
if (pieceElement) {
pieceElement.style.transform = `rotate(${piece.rotation}deg)`;
}
}
function swapPieces(pieceUuidA, pieceUuidB) {
const pieceA = pieceMap[pieceUuidA];
const pieceB = pieceMap[pieceUuidB];
// Swap currentIndex
const tempIndex = pieceA.currentIndex;
pieceA.currentIndex = pieceB.currentIndex;
pieceB.currentIndex = tempIndex;
// Update positions in DOM
updatePiecePositionInDOM(pieceA);
updatePiecePositionInDOM(pieceB);
}
function updatePiecePositionInDOM(piece) {
const pieceElement = document.querySelector(`[data-piece-uuid="${piece.uuid}"]`);
if (pieceElement) {
const pieceSizePercent = 100 / gridSize;
const currentRow = Math.floor(piece.currentIndex / gridSize);
const currentCol = piece.currentIndex % gridSize;
pieceElement.style.left = `${currentCol * pieceSizePercent}%`;
pieceElement.style.top = `${currentRow * pieceSizePercent}%`;
pieceElement.dataset.currentIndex = piece.currentIndex;
}
}
function updateScores() {
moveCountDisplay.textContent = moveCount;
rotationCountDisplay.textContent = rotationCount;
}
function recordAction(action, data) {
fetch("{{ request.route_url('record_action', size=puzzle.size) }}", {
method: "POST",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action, data: data })
})
.then(res => res.json())
.then(response => {
if (response.status === 'win') {
// Handle win condition
showWinModal(response.rank);
lockBoard();
} else if (response.status !== 'ok') {
console.error('Error recording action:', response.message);
}
})
.catch(err => console.error(err));
}
function lockBoard() {
isSolved = true;
const pieces = document.querySelectorAll(".puzzle-piece");
pieces.forEach(piece => {
piece.draggable = false;
piece.removeEventListener("click", rotatePiece);
// Optionally, reset rotation and position
piece.style.transform = 'rotate(0deg)';
});
downloadButton.style.display = "block";
}
function showWinModal(rank) {
if (rank !== null) {
const rankDisplay = document.createElement('p');
rankDisplay.textContent = 'Your Rank: ' + rank;
const modalContent = document.querySelector('.modal-content');
modalContent.insertBefore(rankDisplay, modalDownloadButton);
}
winModal.style.display = 'block';
winModalClose.onclick = function() {
winModal.style.display = 'none';
};
window.onclick = function(event) {
if (event.target === winModal) {
winModal.style.display = 'none';
}
};
// Confetti effect
for (let i = 0; i < 100; i++) {
createConfetti();
}
}
function createConfetti() {
const confetti = document.createElement('div');
confetti.className = 'confetti';
confetti.style.left = Math.random() * 100 + '%';
confetti.style.backgroundColor = 'hsl(' + Math.random() * 360 + ', 100%, 50%)';
confetti.style.animationDelay = Math.random() * 3 + 's';
document.body.appendChild(confetti);
setTimeout(() => { confetti.remove(); }, 3000);
}
// Download button functionality
function downloadImage() {
const link = document.createElement('a');
link.href = "{{ puzzle_image_url }}";
link.download = "{{ puzzle_filename }}";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
downloadButton.addEventListener('click', downloadImage);
modalDownloadButton.addEventListener('click', downloadImage);
initBoard();
updateScores();
if (isSolved) {
lockBoard();
}
});
</script>
{% else %}
<h1>No puzzle available!</h1>
{% endif %}
{% endblock %}

12
templates/home.html.j2 Normal file
View file

@ -0,0 +1,12 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>Daily Puzzles - Home</h1>
{% if request.user %}
<p>You are logged in as <strong>{{ request.user.username }}</strong>.</p>
{% else %}
<p>You are not logged in.</p>
<p>Your guest name: <strong>{{ guest_name }}</strong></p>
{% endif %}
<p>Welcome to the Daily Puzzles! Test your skills with our daily challenges.</p>
<p><a href="{{ request.route_url('puzzle_menu') }}">Play Today's Puzzles</a></p>
{% endblock %}

View file

@ -0,0 +1,18 @@
{% extends 'base.html.j2' %}
{% block title %}Invite Admin{% endblock %}
{% block content %}
<h1>Invite a New Admin</h1>
<form method="POST">
<label for="email">User Email:</label>
<input type="email" name="email" required>
<button type="submit">Send Invitation</button>
</form>
{% if message %}
<p>{{ message }}</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>King of the Mountain - Global Leaderboard</h1>
<table>
<tr>
<th>Rank</th>
<th>Username</th>
<th>Total Puzzles Solved</th>
<th>Total Time (sec)</th>
<th>Total Moves</th>
<th>Total Rotations</th>
<th>Total Actions</th>
</tr>
{% for player in global_leaderboard %}
<tr>
<td>{{ loop.index }}</td>
<td><a href="{{ request.route_url('view_profile', user_id=player.user_id) }}">{{ player.username }}</a></td>
<td>{{ player.total_puzzles }}</td>
<td>{{ "%.2f"|format(player.total_time) }}</td>
<td>{{ player.total_moves }}</td>
<td>{{ player.total_rotations }}</td>
<td>{{ player.total_moves + player.total_rotations }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>All Leaderboards</h1>
<p>Click a puzzle date to see its full scoreboard.</p>
<table>
<tr>
<th>Puzzle Date</th>
<th>Size</th>
<th>Title</th>
<th>Actions</th>
</tr>
{% for p in puzzles %}
<tr>
<td>{{ p.date }}</td>
<td>{{ p.size }}x{{ p.size }}</td>
<td>{{ p.title }}</td>
<td><a href="{{ request.route_url('daily_leaderboard', puzzle_id=p.id) }}">View Leaderboard</a></td>
</tr>
{% endfor %}
</table>
{% endblock %}

10
templates/login.html.j2 Normal file
View file

@ -0,0 +1,10 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>Login</h1>
<p>Enter your email to receive a 6-digit code.</p>
<form method="POST">
<label for="email">Email:</label>
<input type="email" name="email" required>
<button type="submit">Get Code</button>
</form>
{% endblock %}

View file

@ -0,0 +1,40 @@
{% extends 'base.html.j2' %}
{% block title %}Manage Admins{% endblock %}
{% block content %}
<h1>Manage Admins</h1>
<table>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for admin in admins %}
<tr>
<td>{{ admin.username }}</td>
<td>{{ admin.email }}</td>
<td>
{% if admin.id != request.user.id %}
<form method="POST" action="{{ request.route_url('remove_admin', user_id=admin.id) }}" style="display:inline;">
<button type="submit" onclick="return confirm('Are you sure you want to revoke admin privileges from {{ admin.username }}?')">Revoke Admin</button>
</form>
{% else %}
<em>Current User</em>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if message %}
<p>{{ message }}</p>
{% endif %}
<p><a href="{{ request.route_url('invite_admin') }}">Invite a New Admin</a></p>
{% endblock %}

47
templates/profile.html.j2 Normal file
View file

@ -0,0 +1,47 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>Your Profile</h1>
<p>Username: {{ user.username or "Not set" }}</p>
{% if user.enable_gravatar %}
<img src="{{ gravatar_url }}" alt="Gravatar Image">
{% else %}
<p>Gravatar is disabled.</p>
{% endif %}
<form method="POST">
<label>
<input type="checkbox" name="enable_gravatar" {% if user.enable_gravatar %}checked{% endif %}>
Enable Gravatar
</label><br><br>
<label>
New Username:
<input type="text" name="new_username" placeholder="Enter new username">
</label><br><br>
<button type="submit">Update Profile</button>
</form>
<h2>Stats</h2>
<p>Total Puzzles Solved: {{ total_puzzles_solved }}</p>
<p>Current Streak: {{ current_streak }} days</p>
<p>Max Streak: {{ max_streak }} days</p>
<h2>Your Solves</h2>
<table>
<tr>
<th>Date</th>
<th>Size</th>
<th>Moves</th>
<th>Rotations</th>
<th>Time (sec)</th>
</tr>
{% for attempt in solved_attempts %}
<tr>
<td>{{ attempt.puzzle.date }}</td>
<td>{{ attempt.puzzle.size }}x{{ attempt.puzzle.size }}</td>
<td>{{ attempt.move_count }}</td>
<td>{{ attempt.rotation_count }}</td>
<td>{{ "%.2f"|format((attempt.time_completed - attempt.time_started).total_seconds()) }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>Select a Puzzle Size</h1>
<ul>
{% for size in puzzle_sizes %}
{% if puzzles[size] %}
<li><a href="{{ request.route_url('daily_puzzle', size=size) }}">{{ size }}x{{ size }} Puzzle</a></li>
{% else %}
<li>{{ size }}x{{ size }} Puzzle - Not Available Today</li>
{% endif %}
{% endfor %}
</ul>
{% endblock %}

16
templates/upload.html.j2 Normal file
View file

@ -0,0 +1,16 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>Upload Puzzles</h1>
<form method="POST" enctype="multipart/form-data">
<label for="puzzle_date">Puzzle Date (YYYY-MM-DD):</label>
<input type="text" name="puzzle_date" required><br><br>
{% for size in [3, 4, 5] %}
<h3>{{ size }}x{{ size }} Puzzle</h3>
<label for="title_{{ size }}">Puzzle Title:</label>
<input type="text" name="title_{{ size }}" value="Puzzle {{ size }}x{{ size }}"><br><br>
<label for="image_file_{{ size }}">Choose Image:</label>
<input type="file" name="image_file_{{ size }}" accept="image/*"><br><br>
{% endfor %}
<button type="submit">Upload</button>
</form>
{% endblock %}

9
templates/verify.html.j2 Normal file
View file

@ -0,0 +1,9 @@
{% extends 'base.html.j2' %}
{% block content %}
<h1>Enter 6-digit code</h1>
<form method="POST">
<label for="code">Code:</label>
<input type="text" name="code" required>
<button type="submit">Verify</button>
</form>
{% endblock %}