auto create guest users
modified: app.py modified: templates/base.html.j2 modified: templates/daily_puzzle.html.j2
This commit is contained in:
parent
98a1b02b87
commit
db81a7aa8e
3 changed files with 249 additions and 197 deletions
315
app.py
315
app.py
|
|
@ -47,8 +47,8 @@ Base = declarative_base()
|
|||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True)
|
||||
email = Column(String, unique=True, nullable=False) # used for login
|
||||
username = Column(String, unique=True, nullable=True) # user-chosen handle
|
||||
email = Column(String, unique=True, nullable=True) # guests don't have an email.
|
||||
username = Column(String, unique=True, nullable=False) # user-chosen handle
|
||||
code_hash = Column(String, nullable=True) # bcrypt hash of code
|
||||
code_expires = Column(DateTime, nullable=True) # time limit for code
|
||||
is_verified = Column(Boolean, default=False)
|
||||
|
|
@ -168,6 +168,29 @@ class Attempt(Base):
|
|||
return int(self.time_started.timestamp() * 1000)
|
||||
return 0
|
||||
|
||||
def mark_as_solved(self, time_completed=None):
|
||||
"""Mark the attempt as solved and set time_completed."""
|
||||
self.is_solved = True
|
||||
if self.time_completed is None:
|
||||
self.time_completed = time_completed or datetime.datetime.utcnow()
|
||||
|
||||
@property
|
||||
def total_elapsed_ms(self):
|
||||
"""Calculate total elapsed time in milliseconds if solved."""
|
||||
if self.time_completed and self.time_started:
|
||||
delta = self.time_completed - self.time_started
|
||||
return int(delta.total_seconds() * 1000)
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_elapsed_ms(self):
|
||||
"""Calculate current elapsed time in milliseconds."""
|
||||
if self.time_started:
|
||||
end_time = self.time_completed or datetime.datetime.utcnow()
|
||||
delta = end_time - self.time_started
|
||||
return int(delta.total_seconds() * 1000)
|
||||
return None
|
||||
|
||||
|
||||
DB_URL = "sqlite:///puzzle_game.db"
|
||||
engine = create_engine(DB_URL, echo=False)
|
||||
|
|
@ -227,37 +250,94 @@ def admin_required(view_func):
|
|||
return wrapper
|
||||
|
||||
|
||||
################################################################################
|
||||
# Helper / Auth Functions
|
||||
################################################################################
|
||||
def get_current_user(request):
|
||||
"""Return the currently logged-in user (if any) from session."""
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
s = request.dbsession
|
||||
return s.query(User).filter_by(id=user_id, is_verified=True).first()
|
||||
"""Return the current user (authenticated or guest) from session."""
|
||||
user_id = request.session.get("user_id")
|
||||
s = request.dbsession
|
||||
|
||||
if user_id:
|
||||
# Try to get the user from the database
|
||||
user = s.query(User).filter_by(id=user_id).first()
|
||||
if user:
|
||||
return user
|
||||
else:
|
||||
# User ID in session does not exist in the database; remove it
|
||||
del request.session["user_id"]
|
||||
|
||||
# No valid user in session; create a guest user
|
||||
# Generate a unique guest username
|
||||
while True:
|
||||
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||||
guest_username = f"Guest-{suffix}"
|
||||
existing_user = s.query(User).filter_by(username=guest_username).first()
|
||||
if not existing_user:
|
||||
break # Unique username found
|
||||
|
||||
# Create a new guest user
|
||||
guest_user = User(
|
||||
email=guest_username,
|
||||
username=guest_username,
|
||||
is_verified=False,
|
||||
)
|
||||
s.add(guest_user)
|
||||
s.commit()
|
||||
|
||||
# Store the user ID in the session
|
||||
request.session["user_id"] = guest_user.id
|
||||
|
||||
return guest_user
|
||||
|
||||
|
||||
def ensure_guest_name_in_session(request):
|
||||
"""If no guest_name is in session, generate one like 'Guest-ABCD'."""
|
||||
if "guest_name" not in request.session:
|
||||
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=4))
|
||||
request.session["guest_name"] = f"Guest-{suffix}"
|
||||
def get_ordered_attempts(s, puzzle_id, sort_method='time'):
|
||||
"""Retrieve and order attempts for a given puzzle based on the sort method."""
|
||||
if sort_method == "moves":
|
||||
# Sort by least total actions (moves + rotations), only considering first attempts
|
||||
attempts = (
|
||||
s.query(Attempt)
|
||||
.filter(
|
||||
Attempt.puzzle_id == puzzle_id,
|
||||
Attempt.is_solved == True,
|
||||
Attempt.is_counted == True,
|
||||
Attempt.attempt_number == 1,
|
||||
)
|
||||
.order_by(
|
||||
(Attempt.move_count + Attempt.rotation_count).asc(),
|
||||
(
|
||||
func.julianday(Attempt.time_completed)
|
||||
- func.julianday(Attempt.time_started)
|
||||
).asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
# Default sort by fastest time, only considering first attempts
|
||||
attempts = (
|
||||
s.query(Attempt)
|
||||
.filter(
|
||||
Attempt.puzzle_id == puzzle_id,
|
||||
Attempt.is_solved == True,
|
||||
Attempt.is_counted == True,
|
||||
Attempt.attempt_number == 1,
|
||||
)
|
||||
.order_by(
|
||||
(
|
||||
func.julianday(Attempt.time_completed)
|
||||
- func.julianday(Attempt.time_started)
|
||||
).asc(),
|
||||
(Attempt.move_count + Attempt.rotation_count).asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return attempts
|
||||
|
||||
|
||||
################################################################################
|
||||
# Routes
|
||||
################################################################################
|
||||
|
||||
|
||||
@view_config(route_name="home", renderer="home.html.j2")
|
||||
def home_view(request):
|
||||
ensure_guest_name_in_session(request)
|
||||
guest_name = request.session["guest_name"]
|
||||
return {
|
||||
"request": request,
|
||||
"guest_name": guest_name,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -276,8 +356,19 @@ def login_post_view(request):
|
|||
user = session.query(User).filter_by(email=email).first()
|
||||
|
||||
if not user:
|
||||
# Create new user with null username for now
|
||||
user = User(email=email, username=None)
|
||||
|
||||
# No valid user in session; create a new user
|
||||
# Generate a unique starting username.
|
||||
while True:
|
||||
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||||
new_tmp_username = f"User-{suffix}"
|
||||
existing_user = s.query(User).filter_by(username=guest_username).first()
|
||||
if not existing_user:
|
||||
# Unique username found
|
||||
break
|
||||
|
||||
# Create new user with tmp username for now
|
||||
user = User(email=email, username=new_tmp_username)
|
||||
|
||||
# First user becomes root admin
|
||||
user_count = session.query(func.count(User.id)).scalar()
|
||||
|
|
@ -301,6 +392,9 @@ def login_post_view(request):
|
|||
email_body = f"Your verification code is: {code_str}"
|
||||
send_email(user.email, "Your Verification Code", email_body)
|
||||
|
||||
# Store the email in the session for verification
|
||||
request.session["login_email"] = email
|
||||
|
||||
return HTTPFound(location=request.route_url("verify"))
|
||||
|
||||
|
||||
|
|
@ -315,33 +409,32 @@ def verify_post_view(request):
|
|||
if not code_entered or len(code_entered) != 6:
|
||||
return Response("Invalid code.", status=400)
|
||||
|
||||
email = request.session.get("login_email")
|
||||
if not email:
|
||||
return Response("No email found in session. Please start the login process again.", status=400)
|
||||
|
||||
s = request.dbsession
|
||||
# Find any user with code_expires > now, is_verified=False, matching code
|
||||
potential_users = (
|
||||
s.query(User)
|
||||
.filter(
|
||||
# User.is_verified == False,
|
||||
User.code_expires > datetime.datetime.now(),
|
||||
User.code_hash != None,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
user = s.query(User).filter(
|
||||
User.email == email,
|
||||
User.code_expires > datetime.datetime.now(),
|
||||
User.code_hash != None,
|
||||
).first()
|
||||
|
||||
user_matched = None
|
||||
for u in potential_users:
|
||||
if bcrypt.checkpw(code_entered.encode("utf-8"), u.code_hash.encode("utf-8")):
|
||||
user_matched = u
|
||||
break
|
||||
|
||||
if not user_matched:
|
||||
if not user:
|
||||
return Response("Code not found or expired.", status=400)
|
||||
|
||||
user_matched.is_verified = True
|
||||
user_matched.code_hash = None
|
||||
user_matched.code_expires = None
|
||||
if not bcrypt.checkpw(code_entered.encode("utf-8"), user.code_hash.encode("utf-8")):
|
||||
return Response("Invalid code.", status=400)
|
||||
|
||||
user.is_verified = True
|
||||
user.code_hash = None
|
||||
user.code_expires = None
|
||||
s.commit()
|
||||
|
||||
request.session["user_id"] = user_matched.id
|
||||
# Remove the email from the session
|
||||
del request.session["login_email"]
|
||||
|
||||
request.session["user_id"] = user.id
|
||||
return HTTPFound(location=request.route_url("home"))
|
||||
|
||||
|
||||
|
|
@ -406,6 +499,8 @@ def profile_get_view(request):
|
|||
def profile_post_view(request):
|
||||
if not request.user:
|
||||
return Response("You must be logged in to update your profile.", status=403)
|
||||
if not request.user.is_verified:
|
||||
return Response("This account is in guest mode, log in to update your profile.", status=403)
|
||||
s = request.dbsession
|
||||
enable_gravatar = request.POST.get("enable_gravatar") == "on"
|
||||
request.user.enable_gravatar = enable_gravatar
|
||||
|
|
@ -549,12 +644,11 @@ def invite_admin_post_view(request):
|
|||
@admin_required
|
||||
def manage_admins_view(request):
|
||||
s = request.dbsession
|
||||
user = get_current_user(request)
|
||||
admins = s.query(User).filter_by(is_admin=True).all()
|
||||
return {
|
||||
"request": request,
|
||||
"admins": admins,
|
||||
"user": user,
|
||||
"user": request.user,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -562,7 +656,7 @@ def manage_admins_view(request):
|
|||
@admin_required
|
||||
def remove_admin_view(request):
|
||||
s = request.dbsession
|
||||
current_user = get_current_user(request)
|
||||
current_user = request.user
|
||||
user_id = int(request.matchdict.get("user_id"))
|
||||
if user_id == current_user.id or user_id == 1:
|
||||
return Response("Cannot remove root admin or yourself.", status=400)
|
||||
|
|
@ -729,107 +823,61 @@ def daily_puzzle_view(request):
|
|||
if not puzzle:
|
||||
return {"request": request, "puzzle": None}
|
||||
|
||||
# Check if user has an existing attempt
|
||||
user = get_current_user(request)
|
||||
ensure_guest_name_in_session(request)
|
||||
guest_name = request.session["guest_name"]
|
||||
user_id = user.id if user else None
|
||||
display_name = user.username if (user and user.username) else guest_name
|
||||
|
||||
# Check if user has already solved this puzzle
|
||||
existing_solve = (
|
||||
s.query(Attempt)
|
||||
.filter_by(
|
||||
user_id=user_id, puzzle_id=puzzle.id, is_solved=True, is_counted=True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
# Get the current user using request.user
|
||||
user = request.user
|
||||
|
||||
# Fetch the first attempt (attempt_number == 1) for the user and puzzle
|
||||
attempt = (
|
||||
s.query(Attempt)
|
||||
.filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False)
|
||||
.order_by(Attempt.attempt_number.desc())
|
||||
.filter_by(user_id=user.id, puzzle_id=puzzle.id, attempt_number=1)
|
||||
.first()
|
||||
)
|
||||
|
||||
attempts_count = (
|
||||
s.query(Attempt).filter_by(user_id=user_id, puzzle_id=puzzle.id).count()
|
||||
)
|
||||
|
||||
if not attempt:
|
||||
# Create a new attempt
|
||||
attempt_number = attempts_count + 1
|
||||
|
||||
# If the user already solved this puzzle, mark new attempts as not counted
|
||||
is_counted = False if existing_solve else True
|
||||
|
||||
# Use initial state or solution state based on whether the puzzle is solved
|
||||
state_json = (
|
||||
existing_solve.state_json if existing_solve else puzzle.initial_state_json
|
||||
)
|
||||
|
||||
# Create the first attempt
|
||||
attempt = Attempt(
|
||||
user_id=user_id,
|
||||
user_display_name=display_name,
|
||||
user_id=user.id,
|
||||
user_display_name=user.username,
|
||||
puzzle_id=puzzle.id,
|
||||
attempt_date=today,
|
||||
attempt_number=attempt_number,
|
||||
attempt_number=1,
|
||||
move_count=0,
|
||||
rotation_count=0,
|
||||
is_solved=bool(existing_solve),
|
||||
state_json=state_json,
|
||||
is_solved=False,
|
||||
state_json=puzzle.initial_state_json,
|
||||
time_started=datetime.datetime.utcnow(),
|
||||
is_counted=is_counted,
|
||||
is_counted=True,
|
||||
)
|
||||
s.add(attempt)
|
||||
s.commit()
|
||||
|
||||
else:
|
||||
# If the puzzle is already solved, ensure the attempt reflects that
|
||||
if existing_solve:
|
||||
attempt.is_solved = True
|
||||
attempt.state_json = existing_solve.state_json
|
||||
s.commit()
|
||||
|
||||
# Get user's rank if they have solved it
|
||||
user_rank = None
|
||||
if existing_solve:
|
||||
attempts = (
|
||||
s.query(Attempt)
|
||||
.filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True)
|
||||
.order_by(
|
||||
(
|
||||
func.julianday(Attempt.time_completed)
|
||||
- func.julianday(Attempt.time_started)
|
||||
).asc(),
|
||||
(Attempt.move_count + Attempt.rotation_count).asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if attempt.is_solved:
|
||||
sort_method = 'time' # You can change this to 'moves' if desired
|
||||
attempts = get_ordered_attempts(s, puzzle.id, sort_method)
|
||||
for idx, att in enumerate(attempts):
|
||||
if att.user_id == user_id:
|
||||
if att.user_id == user.id:
|
||||
user_rank = idx + 1
|
||||
break
|
||||
|
||||
# Calculate server's current time in milliseconds
|
||||
server_time = datetime.datetime.utcnow()
|
||||
server_time_ms = int(server_time.timestamp() * 1000)
|
||||
|
||||
# Calculate initial elapsed time in milliseconds
|
||||
attempt_start_time_ms = attempt.time_started_ms
|
||||
initial_elapsed_ms = server_time_ms - attempt_start_time_ms
|
||||
# Calculate elapsed time
|
||||
if attempt.is_solved:
|
||||
# Use total elapsed time when solved
|
||||
total_elapsed_ms = attempt.total_elapsed_ms or 0
|
||||
else:
|
||||
# Calculate current elapsed time
|
||||
total_elapsed_ms = attempt.current_elapsed_ms or 0
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
"puzzle": puzzle,
|
||||
"attempt": attempt, # Pass the entire attempt object
|
||||
"attempt": attempt,
|
||||
"attempt_state": json.loads(attempt.state_json),
|
||||
"attempt_is_solved": attempt.is_solved or existing_solve is not None,
|
||||
"attempt_is_solved": attempt.is_solved,
|
||||
"user_rank": user_rank,
|
||||
"image_mime_type": puzzle.image_mime_type,
|
||||
"attempt_start_time_ms": attempt_start_time_ms,
|
||||
"server_time_ms": server_time_ms,
|
||||
"initial_elapsed_ms": initial_elapsed_ms,
|
||||
"total_elapsed_ms": total_elapsed_ms,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -842,13 +890,9 @@ def puzzle_image_view(request):
|
|||
return Response("Puzzle not found.", status=404)
|
||||
|
||||
# Check if user has solved this puzzle
|
||||
user = get_current_user(request)
|
||||
ensure_guest_name_in_session(request)
|
||||
user_id = user.id if user else None
|
||||
|
||||
solved_attempt = (
|
||||
s.query(Attempt)
|
||||
.filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=True)
|
||||
.filter_by(user_id=request.user.id, puzzle_id=puzzle.id, is_solved=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
|
@ -876,9 +920,7 @@ def record_action_view(request):
|
|||
data = action_data.get("data")
|
||||
|
||||
# Retrieve user, puzzle, and attempt
|
||||
user = get_current_user(request)
|
||||
ensure_guest_name_in_session(request)
|
||||
guest_name = request.session["guest_name"]
|
||||
user = request.user
|
||||
|
||||
size = int(request.matchdict.get("size", 4))
|
||||
today = datetime.date.today()
|
||||
|
|
@ -890,12 +932,11 @@ def record_action_view(request):
|
|||
content_type="application/json; charset=UTF-8",
|
||||
)
|
||||
|
||||
user_id = user.id if user else None
|
||||
display_name = user.username if (user and user.username) else guest_name
|
||||
display_name = user.username
|
||||
|
||||
attempt = (
|
||||
s.query(Attempt)
|
||||
.filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False)
|
||||
.filter_by(user_id=user.id, puzzle_id=puzzle.id, is_solved=False)
|
||||
.order_by(Attempt.attempt_number.desc())
|
||||
.first()
|
||||
)
|
||||
|
|
@ -960,7 +1001,7 @@ def record_action_view(request):
|
|||
piece["currentIndex"] == piece["correctIndex"] and piece["rotation"] % 360 == 0
|
||||
for piece in state
|
||||
):
|
||||
attempt.is_solved = True
|
||||
attempt.mark_as_solved()
|
||||
attempt.time_completed = datetime.datetime.utcnow()
|
||||
s.commit()
|
||||
|
||||
|
|
@ -978,7 +1019,7 @@ def record_action_view(request):
|
|||
.all()
|
||||
)
|
||||
for idx, att in enumerate(attempts):
|
||||
if att.user_id == user_id and att.id == attempt.id:
|
||||
if att.user_id == user.id and att.id == attempt.id:
|
||||
user_rank = idx + 1
|
||||
break
|
||||
else:
|
||||
|
|
@ -1112,7 +1153,7 @@ def king_of_the_mountain_view(request):
|
|||
leaderboard.append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"username": user.username or user.email,
|
||||
"username": user.username,
|
||||
"total_puzzles": total_puzzles,
|
||||
"total_time": total_time,
|
||||
"total_moves": total_moves,
|
||||
|
|
@ -1129,16 +1170,6 @@ def king_of_the_mountain_view(request):
|
|||
}
|
||||
|
||||
|
||||
def get_current_user(request):
|
||||
"""Return the currently logged-in user (if any) from session."""
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
s = request.dbsession
|
||||
user = s.query(User).filter_by(id=user_id, is_verified=True).first()
|
||||
return user
|
||||
|
||||
|
||||
################################################################################
|
||||
# Main
|
||||
################################################################################
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ nav ul {
|
|||
border: none;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
@ -90,6 +91,11 @@ nav ul {
|
|||
border: none;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#mobile-menu-close:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Ensure links are vertical */
|
||||
|
|
@ -111,7 +117,7 @@ nav ul {
|
|||
<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 %}
|
||||
{% if request.user and request.user.is_verified %}
|
||||
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
|
||||
<li><a href="{{ request.route_url('logout') }}">Logout</a></li>
|
||||
{% else %}
|
||||
|
|
@ -136,7 +142,7 @@ nav ul {
|
|||
<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 %}
|
||||
{% if request.user and request.user.is_verified %}
|
||||
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
|
||||
<li><a href="{{ request.route_url('logout') }}">Logout</a></li>
|
||||
{% else %}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
<h1>{{ puzzle.title }}</h1>
|
||||
<p>Date: {{ puzzle.date }}</p>
|
||||
<p>
|
||||
Moves: <span id="moveCount">{{ attempt.move_count }}</span><br>
|
||||
Moves: <span id="moveCount">{{ attempt.move_count }}</span>
|
||||
Rotations: <span id="rotationCount">{{ attempt.rotation_count }}</span><br>
|
||||
Time Elapsed: <span id="timerDisplay">00:00.00</span><br>
|
||||
Time Elapsed: <span id="timerDisplay">0.00 seconds</span><br>
|
||||
</p>
|
||||
<button id="downloadButton" hidden>Download Image</button>
|
||||
</div>
|
||||
|
|
@ -21,14 +21,21 @@
|
|||
<article>
|
||||
<header>
|
||||
<h2>Congratulations!</h2>
|
||||
<a href="#close" aria-label="Close" class="close" id="winModalClose"></a>
|
||||
<button aria-label="Close" id="winModalClose">✕</button>
|
||||
</header>
|
||||
<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 %}
|
||||
<p>You solved the puzzle! 🎉🎉🎉</p>
|
||||
<div id="stats">
|
||||
<p>
|
||||
Date: {{ puzzle.date }}<br>
|
||||
Moves: {{ attempt.move_count }} Rotations: {{ attempt.rotation_count }}<br>
|
||||
Time Elapsed: {{ '%.2f'|format(total_elapsed_ms / 1000) }} seconds
|
||||
</p>
|
||||
{% if user_rank is not none %}
|
||||
<p>Your Rank: {{ user_rank }}</p>
|
||||
{% else %}
|
||||
<p>Your rank is not available.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<footer>
|
||||
<button id="modalDownloadButton">Download Image</button>
|
||||
</footer>
|
||||
|
|
@ -85,20 +92,30 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
/* Confetti styles */
|
||||
.confetti {
|
||||
position: fixed;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
top: 0;
|
||||
animation: confetti-fall 5s linear infinite;
|
||||
|
||||
#winModalClose {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Confetti animation */
|
||||
@keyframes confetti-fall {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(100vh); }
|
||||
#winModalClose:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Modal header styles */
|
||||
#winModal article header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#winModal article header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<!-- Scripts -->
|
||||
|
|
@ -109,10 +126,8 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||
let isSolved = {{ 'true' if attempt_is_solved else 'false' }};
|
||||
let moveCount = {{ attempt.move_count }};
|
||||
let rotationCount = {{ attempt.rotation_count }};
|
||||
let elapsedMs = {{ total_elapsed_ms | default(0) }};
|
||||
|
||||
// Use the initial elapsed time and increment it
|
||||
let elapsedMs = {{ initial_elapsed_ms }};
|
||||
|
||||
// DOM elements
|
||||
const puzzleContainer = document.getElementById("puzzle-container");
|
||||
const moveCountDisplay = document.getElementById("moveCount");
|
||||
|
|
@ -226,7 +241,6 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||
if (isSolved) return;
|
||||
const touchTarget = event.target;
|
||||
touchStartPieceUuid = touchTarget.dataset.pieceUuid;
|
||||
// Add a visual cue if desired
|
||||
}
|
||||
|
||||
function touchEnd(event) {
|
||||
|
|
@ -308,7 +322,10 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||
.then(response => {
|
||||
if (response.status === 'win') {
|
||||
// Handle win condition
|
||||
showWinModal(response.rank);
|
||||
isSolved = true;
|
||||
setTimeout(function() {
|
||||
showWinModal(response.rank);
|
||||
}, 100); // Delay to ensure modal displays properly
|
||||
lockBoard();
|
||||
} else if (response.status !== 'ok') {
|
||||
console.error('Error recording action:', response.message);
|
||||
|
|
@ -332,12 +349,16 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||
}
|
||||
|
||||
function showWinModal(rank) {
|
||||
if (rank !== null) {
|
||||
const rankDisplay = document.createElement('p');
|
||||
rankDisplay.textContent = 'Your Rank: ' + rank;
|
||||
const modalContent = winModal.querySelector('article');
|
||||
modalContent.insertBefore(rankDisplay, modalContent.querySelector('footer'));
|
||||
}
|
||||
// Update stats in modal
|
||||
const statsDiv = winModal.querySelector('#stats');
|
||||
statsDiv.innerHTML = `
|
||||
<p>Date: {{ puzzle.date }}</p>
|
||||
<p>
|
||||
Moves: ${moveCount} Rotations: ${rotationCount}<br>
|
||||
Time Elapsed: ${formatTime(elapsedMs)}
|
||||
</p>
|
||||
${rank !== null ? `<p>Your Rank: ${rank}</p>` : `<p>Your rank is not available.</p>`}
|
||||
`;
|
||||
|
||||
winModal.showModal();
|
||||
|
||||
|
|
@ -345,21 +366,11 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||
event.preventDefault();
|
||||
winModal.close();
|
||||
};
|
||||
|
||||
// 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);
|
||||
function formatTime(ms) {
|
||||
const seconds = ms / 1000;
|
||||
return `${seconds.toFixed(2)} seconds`;
|
||||
}
|
||||
|
||||
// Download button functionality
|
||||
|
|
@ -381,19 +392,13 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||
function updateTimer() {
|
||||
if (isSolved) {
|
||||
clearInterval(timerInterval); // Stop the timer when the puzzle is solved
|
||||
updateTimerDisplay(); // Ensure the final time is displayed
|
||||
return;
|
||||
}
|
||||
// Increment elapsedMs by 10 milliseconds
|
||||
elapsedMs += 10;
|
||||
|
||||
const minutes = Math.floor(elapsedMs / 60000);
|
||||
const seconds = Math.floor((elapsedMs % 60000) / 1000);
|
||||
const centiseconds = Math.floor((elapsedMs % 1000) / 10); // Get centiseconds
|
||||
timerDisplay.textContent = `${padZero(minutes)}:${padZero(seconds)}.${padZero(centiseconds)}`;
|
||||
}
|
||||
|
||||
function padZero(num) {
|
||||
return num.toString().padStart(2, '0');
|
||||
updateTimerDisplay();
|
||||
}
|
||||
|
||||
// Update the timer every 10 milliseconds
|
||||
|
|
@ -401,13 +406,23 @@ document.addEventListener("DOMContentLoaded", function() {
|
|||
timerInterval = setInterval(updateTimer, 10); // Update every 10 milliseconds
|
||||
}
|
||||
|
||||
function updateTimerDisplay() {
|
||||
timerDisplay.textContent = formatTime(elapsedMs);
|
||||
}
|
||||
|
||||
// Initialize everything
|
||||
initBoard();
|
||||
updateScores();
|
||||
startTimer();
|
||||
|
||||
if (isSolved) {
|
||||
lockBoard();
|
||||
updateTimerDisplay();
|
||||
// Delay the modal show call to ensure it works across all browsers
|
||||
setTimeout(function() {
|
||||
showWinModal({{ user_rank | tojson | default('null') }});
|
||||
}, 100); // Delay by 100 milliseconds
|
||||
} else {
|
||||
startTimer();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue