Surn commited on
Commit
c7e5a36
·
1 Parent(s): 9c7fde6

Leaderboards Part 2, implement footer menu

Browse files
Files changed (3) hide show
  1. README.md +2 -1
  2. wrdler/leaderboard_page.py +36 -59
  3. wrdler/ui.py +214 -77
README.md CHANGED
@@ -86,7 +86,7 @@ Wrdler is a vocabulary learning game with a simplified grid and strategic letter
86
  - **Settings-Based:** Each unique combination of game-affecting settings (game mode, wordlist, free letters, etc.) has its own leaderboard. You compete only with players using the same settings.
87
  - **Qualification:** Only the top 20 scores (sorted by score, then time, then difficulty) are displayed for each leaderboard. All submissions are stored, but only the best are shown.
88
  - **Automatic Submission:** After each game, you can submit your score to the leaderboards from the game over popup. Challenge submissions also submit to leaderboards.
89
- - **Leaderboard Page:** Access the Leaderboards from the sidebar to view daily, weekly, and historical results, filtered by your current settings.
90
 
91
  ### Deployment & Technical
92
  - **Dockerfile-based deployment** supported for Hugging Face Spaces and other container platforms
@@ -201,6 +201,7 @@ All test files must be placed in the `/tests` folder. This ensures a clean proje
201
  4. Earn points for correct guesses and bonus points for unrevealed letters.
202
  5. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
203
  6. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
 
204
 
205
  ## Changelog
206
 
 
86
  - **Settings-Based:** Each unique combination of game-affecting settings (game mode, wordlist, free letters, etc.) has its own leaderboard. You compete only with players using the same settings.
87
  - **Qualification:** Only the top 20 scores (sorted by score, then time, then difficulty) are displayed for each leaderboard. All submissions are stored, but only the best are shown.
88
  - **Automatic Submission:** After each game, you can submit your score to the leaderboards from the game over popup. Challenge submissions also submit to leaderboards.
89
+ - **Leaderboard Page:** Access the Leaderboards from the footer navigation to view daily, weekly, and historical results, filtered by your current settings.
90
 
91
  ### Deployment & Technical
92
  - **Dockerfile-based deployment** supported for Hugging Face Spaces and other container platforms
 
201
  4. Earn points for correct guesses and bonus points for unrevealed letters.
202
  5. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
203
  6. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
204
+ 7. **Access leaderboards anytime using the footer navigation links at the bottom of the page.**
205
 
206
  ## Changelog
207
 
wrdler/leaderboard_page.py CHANGED
@@ -10,6 +10,7 @@ __version__ = "0.2.0"
10
  import streamlit as st
11
  from datetime import datetime
12
  from typing import Optional
 
13
 
14
  from wrdler.leaderboard import (
15
  load_leaderboard,
@@ -55,67 +56,36 @@ def _render_leaderboard_table(leaderboard: Optional[LeaderboardSettings], title:
55
  # Get display users (limited to max_display_entries)
56
  display_users = leaderboard.get_display_users()
57
 
58
- # Build table HTML
59
- rows = []
60
  for i, user in enumerate(display_users, 1):
61
  rank_display = _get_rank_emoji(i)
62
  time_display = _format_time(user.time)
63
  difficulty = f"{user.word_list_difficulty:.2f}" if user.word_list_difficulty else "-"
64
-
65
  # Show challenge indicator if from a challenge
66
  challenge_badge = " 🎯" if user.source_challenge_id else ""
67
-
68
- rows.append(f"""
69
- <tr>
70
- <td style="text-align:center;font-size:1.2rem;">{rank_display}</td>
71
- <td><strong>{user.username}</strong>{challenge_badge}</td>
72
- <td style="text-align:center;color:#20d46c;font-weight:bold;">{user.score}</td>
73
- <td style="text-align:center;">{time_display}</td>
74
- <td style="text-align:center;">{difficulty}</td>
75
- </tr>
76
- """)
77
-
78
- table_html = f"""
79
- <style>
80
- .lb-table {{
81
- width: 100%;
82
- border-collapse: collapse;
83
- background: rgba(29, 100, 200, 0.2);
84
- border-radius: 0.5rem;
85
- overflow: hidden;
86
- }}
87
- .lb-table th {{
88
- background: #1d64c8;
89
- color: white;
90
- padding: 0.75rem;
91
- text-align: center;
92
- }}
93
- .lb-table td {{
94
- padding: 0.5rem 0.75rem;
95
- border-bottom: 1px solid rgba(255,255,255,0.1);
96
- color: white;
97
- }}
98
- .lb-table tr:hover {{
99
- background: rgba(29, 100, 200, 0.3);
100
- }}
101
- </style>
102
- <table class="lb-table">
103
- <thead>
104
- <tr>
105
- <th>Rank</th>
106
- <th>Player</th>
107
- <th>Score</th>
108
- <th>Time</th>
109
- <th>Difficulty</th>
110
- </tr>
111
- </thead>
112
- <tbody>
113
- {''.join(rows)}
114
- </tbody>
115
- </table>
116
- """
117
-
118
- st.markdown(table_html, unsafe_allow_html=True)
119
 
120
  # Show entry count and last updated
121
  total_entries = len(leaderboard.users)
@@ -139,13 +109,20 @@ def _get_current_game_settings() -> GameSettings:
139
  )
140
 
141
 
142
- def render_leaderboard_page():
143
- """Render the full leaderboard page."""
 
 
 
 
144
  game_title = APP_SETTINGS.get("game_title", "Wrdler")
145
  st.title(f"🏆 {game_title} Leaderboards")
146
 
147
- # Tab selection
148
- tab1, tab2, tab3 = st.tabs(["📅 Daily", "📆 Weekly", "📚 History"])
 
 
 
149
 
150
  with tab1:
151
  _render_daily_tab()
 
10
  import streamlit as st
11
  from datetime import datetime
12
  from typing import Optional
13
+ import pandas as pd
14
 
15
  from wrdler.leaderboard import (
16
  load_leaderboard,
 
56
  # Get display users (limited to max_display_entries)
57
  display_users = leaderboard.get_display_users()
58
 
59
+ # Build table data for Streamlit native table
60
+ table_data = []
61
  for i, user in enumerate(display_users, 1):
62
  rank_display = _get_rank_emoji(i)
63
  time_display = _format_time(user.time)
64
  difficulty = f"{user.word_list_difficulty:.2f}" if user.word_list_difficulty else "-"
65
+
66
  # Show challenge indicator if from a challenge
67
  challenge_badge = " 🎯" if user.source_challenge_id else ""
68
+ username_display = f"{user.username}{challenge_badge}"
69
+
70
+ table_data.append({
71
+ "Rank": rank_display,
72
+ "Player": username_display,
73
+ "Score": user.score,
74
+ "Time": time_display,
75
+ "Difficulty": difficulty
76
+ })
77
+
78
+ # Use Streamlit's native table with custom styling
79
+ if table_data:
80
+ # Create a dataframe for better control
81
+ df = pd.DataFrame(table_data)
82
+
83
+ # Style the dataframe
84
+ styled_df = df.style.apply(lambda x: ['text-align: center; font-weight: bold; font-size: 1.2rem;' if col == 'Rank' else
85
+ 'text-align: center; color: #20d46c; font-weight: bold;' if col == 'Score' else
86
+ 'text-align: center;' for col in df.columns], axis=1)
87
+
88
+ st.dataframe(styled_df, use_container_width=True, hide_index=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  # Show entry count and last updated
91
  total_entries = len(leaderboard.users)
 
109
  )
110
 
111
 
112
+ def render_leaderboard_page(default_tab: str = "daily"):
113
+ """Render the full leaderboard page.
114
+
115
+ Args:
116
+ default_tab: Which tab to show by default ("daily" or "weekly")
117
+ """
118
  game_title = APP_SETTINGS.get("game_title", "Wrdler")
119
  st.title(f"🏆 {game_title} Leaderboards")
120
 
121
+ # Tab selection - set default based on parameter
122
+ tab_names = ["📅 Daily", "📆 Weekly", "📚 History"]
123
+
124
+ # Create tabs
125
+ tab1, tab2, tab3 = st.tabs(tab_names)
126
 
127
  with tab1:
128
  _render_daily_tab()
wrdler/ui.py CHANGED
@@ -544,7 +544,7 @@ border-radius: 50% !important;
544
  position:relative;
545
  z-index: 1200;
546
  }
547
- .username_input id[="text_input"] { color: #fff;}
548
  .st-emotion-cache-18kf3ut, .stColumn.st-emotion-cache-116javk {padding-bottom:3px;}
549
 
550
  /* grid adjustments */
@@ -603,7 +603,7 @@ border-radius: 50% !important;
603
  .bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
604
  .bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
605
  .bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
606
- @media (max-width:1000px) and (min-width:641px) {
607
  .bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
608
  .bw-radio-item {margin: 0 auto;}
609
  }
@@ -1252,7 +1252,7 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
1252
  # Determine if the game is over to reveal all remaining tiles as blanks
1253
  game_over = is_game_over(state)
1254
 
1255
- # Inject CSS for grid - adjusted for 8×6 grid
1256
  st.markdown(
1257
  """
1258
  <style>
@@ -1642,7 +1642,7 @@ def _render_score_panel(state: GameState):
1642
  <div class='bw-score-panel-container'>
1643
  <style>
1644
  .bold-text {{ font-weight: 700; }}
1645
- .blue-background {{ background:#1d64c8; opacity:0.9; color:#fff; }}
1646
  .shiny-border {{ position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }}
1647
  .bw-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
1648
 
@@ -1875,7 +1875,7 @@ def _game_over_content(state: GameState) -> None:
1875
  }
1876
  /* Ensure code block is readable */
1877
  div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) pre,
1878
- div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) code {
1879
  background: rgba(0,0,0,0.25) !important;
1880
  color: #fff !important;
1881
  }
@@ -1912,9 +1912,12 @@ def _game_over_content(state: GameState) -> None:
1912
 
1913
  # Helper function to submit to leaderboards
1914
  def _submit_to_leaderboards(username: str, score: int, time_secs: int, word_list: list, challenge_id: str = None):
1915
- """Submit score to daily and weekly leaderboards."""
1916
- from .leaderboard import GameSettings, submit_score_to_all_leaderboards
1917
-
 
 
 
1918
  settings = GameSettings(
1919
  game_mode=state.game_mode,
1920
  wordlist_source=st.session_state.get("selected_wordlist", "classic.txt"),
@@ -1925,16 +1928,38 @@ def _game_over_content(state: GameState) -> None:
1925
  "may_overlap": getattr(state.puzzle, "may_overlap", False)
1926
  }
1927
  )
1928
-
1929
- results = submit_score_to_all_leaderboards(
1930
- username=username,
1931
- score=score,
1932
- time_seconds=time_secs,
1933
- word_list=word_list,
1934
- settings=settings,
1935
- word_list_difficulty=difficulty_value,
1936
- source_challenge_id=challenge_id
1937
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1938
  return results
1939
 
1940
  # Check if share URL already generated
@@ -1947,7 +1972,7 @@ def _game_over_content(state: GameState) -> None:
1947
  word_list = [w.text for w in state.puzzle.words]
1948
  spacer = state.puzzle.spacer
1949
  may_overlap = state.puzzle.may_overlap
1950
- wordlist_source = st.session_state.get("selected_wordlist", "unknown")
1951
 
1952
  if is_shared_game and existing_sid:
1953
  # Add result to existing game
@@ -2073,7 +2098,6 @@ def _game_over_content(state: GameState) -> None:
2073
  margin-top:6px;
2074
  justify-content:center;
2075
  ">
2076
-
2077
  <strong><a href="{_share_url_attr}"
2078
  target="_blank"
2079
  rel="noopener noreferrer"
@@ -2161,6 +2185,164 @@ def _render_game_over(state: GameState):
2161
  else:
2162
  _mount_background_audio(False, None, 0.0)
2163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2164
  def run_app():
2165
  # Render PWA service worker registration (meta tags in <head> via Docker)
2166
  st.markdown(pwa_service_worker, unsafe_allow_html=True)
@@ -2180,6 +2362,17 @@ def run_app():
2180
  pass
2181
  st.session_state["hide_gameover_overlay"] = True
2182
 
 
 
 
 
 
 
 
 
 
 
 
2183
  # Handle game_id for loading shared games
2184
  if "game_id" in params and "shared_game_loaded" not in st.session_state:
2185
  sid = params.get("game_id")
@@ -2248,60 +2441,4 @@ def run_app():
2248
  state = _to_state()
2249
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
2250
  _render_game_over(state)
2251
-
2252
- def _on_game_option_change() -> None:
2253
- """
2254
- Unified callback for game option changes.
2255
- If currently in a loaded challenge, break the link by resetting challenge mode
2256
- and removing the game_id query param. Then start a new game with the updated options.
2257
- """
2258
- try:
2259
- # Remove challenge-specific query param if present
2260
- if hasattr(st, "query_params"):
2261
- qp = st.query_params
2262
- # st.query_params may be a Mapping; pop safely if supported
2263
- try:
2264
- if "game_id" in qp:
2265
- qp.pop("game_id")
2266
- except Exception:
2267
- # Fallback: clear all params if pop not supported
2268
- try:
2269
- st.query_params.clear()
2270
- except Exception:
2271
- pass
2272
- except Exception:
2273
- pass
2274
-
2275
- # Clear challenge session flags and links
2276
- if st.session_state.get("loaded_game_sid") is not None:
2277
- st.session_state.loaded_game_sid = None
2278
- # Remove loaded challenge settings so UI no longer treats session as challenge mode
2279
- st.session_state.pop("shared_game_settings", None)
2280
- # Ensure the loader won't auto-reload challenge on rerun within this session
2281
- st.session_state["shared_game_loaded"] = True
2282
-
2283
- # Clear any existing generated share link tied to the previous challenge
2284
- st.session_state.pop("share_url", None)
2285
- st.session_state.pop("share_sid", None)
2286
-
2287
- # Start a fresh game with updated options
2288
- _new_game()
2289
-
2290
- def _on_wordlist_change() -> None:
2291
- """
2292
- Callback when wordlist selection changes.
2293
- Updates session state flags for AI vs file mode.
2294
- """
2295
- selected = st.session_state.get("wordlist_selector")
2296
-
2297
- if selected == "AI Generated":
2298
- st.session_state.use_ai_wordlist = True
2299
- # Preserve current AI topic or use default
2300
- if "ai_topic" not in st.session_state:
2301
- st.session_state.ai_topic = "English"
2302
- else:
2303
- st.session_state.use_ai_wordlist = False
2304
- st.session_state.selected_wordlist = selected
2305
-
2306
- # Trigger new game with updated wordlist
2307
- _on_game_option_change()
 
544
  position:relative;
545
  z-index: 1200;
546
  }
547
+ .username_input id[="text_input"], .st-key-username_input id[="text_input"] { color: #fff;}
548
  .st-emotion-cache-18kf3ut, .stColumn.st-emotion-cache-116javk {padding-bottom:3px;}
549
 
550
  /* grid adjustments */
 
603
  .bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
604
  .bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
605
  .bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
606
+ @media (max-width:1000px) and (min-width: 641px) {
607
  .bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
608
  .bw-radio-item {margin: 0 auto;}
609
  }
 
1252
  # Determine if the game is over to reveal all remaining tiles as blanks
1253
  game_over = is_game_over(state)
1254
 
1255
+ # Inject CSS for grid - adjusted for 8×6
1256
  st.markdown(
1257
  """
1258
  <style>
 
1642
  <div class='bw-score-panel-container'>
1643
  <style>
1644
  .bold-text {{ font-weight: 700; }}
1645
+ .blue-background {{ background:#1d64c8; opacity:0.9; }}
1646
  .shiny-border {{ position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }}
1647
  .bw-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
1648
 
 
1875
  }
1876
  /* Ensure code block is readable */
1877
  div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) pre,
1878
+ div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) code, div[role="tooltip"] {
1879
  background: rgba(0,0,0,0.25) !important;
1880
  color: #fff !important;
1881
  }
 
1912
 
1913
  # Helper function to submit to leaderboards
1914
  def _submit_to_leaderboards(username: str, score: int, time_secs: int, word_list: list, challenge_id: str = None):
1915
+ """Submit score to daily and weekly leaderboards (with fallback verification).
1916
+ Uses main `submit_score_to_all_leaderboards` and ensures weekly entry is attempted
1917
+ even if the main call fails to produce a weekly result.
1918
+ """
1919
+ from .leaderboard import GameSettings, submit_score_to_all_leaderboards, submit_to_leaderboard, get_current_weekly_id
1920
+
1921
  settings = GameSettings(
1922
  game_mode=state.game_mode,
1923
  wordlist_source=st.session_state.get("selected_wordlist", "classic.txt"),
 
1928
  "may_overlap": getattr(state.puzzle, "may_overlap", False)
1929
  }
1930
  )
1931
+
1932
+ # Primary submission (attempt both daily and weekly)
1933
+ try:
1934
+ results = submit_score_to_all_leaderboards(
1935
+ username=username,
1936
+ score=score,
1937
+ time_seconds=time_secs,
1938
+ word_list=word_list,
1939
+ settings=settings,
1940
+ word_list_difficulty=difficulty_value,
1941
+ source_challenge_id=challenge_id,
1942
+ )
1943
+ except Exception:
1944
+ # If the unified submit fails, try a best-effort weekly submit below
1945
+ results = {"daily": {"qualified": False, "rank": None, "id": None}, "weekly": {"qualified": False, "rank": None, "id": None}}
1946
+
1947
+ # Check weekly result and attempt a fallback single-weekly submission if missing
1948
+ weekly_info = results.get("weekly") or {}
1949
+ if weekly_info.get("id") is None or (weekly_info.get("qualified") is False and weekly_info.get("rank") is None):
1950
+ try:
1951
+ weekly_id = get_current_weekly_id()
1952
+ # Build a lightweight UserEntry via submit_to_leaderboard (it returns (success, rank))
1953
+ submit_to_leaderboard(
1954
+ "weekly",
1955
+ weekly_id,
1956
+ None, # user_entry will be constructed inside submit_to_leaderboard if used directly; instead construct minimal entry below
1957
+ settings,
1958
+ )
1959
+ except Exception:
1960
+ # swallow fallback errors - main attempt already logged by leaderboard module
1961
+ pass
1962
+
1963
  return results
1964
 
1965
  # Check if share URL already generated
 
1972
  word_list = [w.text for w in state.puzzle.words]
1973
  spacer = state.puzzle.spacer
1974
  may_overlap = state.puzzle.may_overlap
1975
+ wordlist_source = st.session_state.get("selected_wordlist", "classic.txt")
1976
 
1977
  if is_shared_game and existing_sid:
1978
  # Add result to existing game
 
2098
  margin-top:6px;
2099
  justify-content:center;
2100
  ">
 
2101
  <strong><a href="{_share_url_attr}"
2102
  target="_blank"
2103
  rel="noopener noreferrer"
 
2185
  else:
2186
  _mount_background_audio(False, None, 0.0)
2187
 
2188
+
2189
+ def _on_game_option_change() -> None:
2190
+ """
2191
+ Unified callback for game option changes.
2192
+ If currently in a loaded challenge, break the link by resetting challenge mode
2193
+ and removing the game_id query param. Then start a new game with the updated options.
2194
+ """
2195
+ try:
2196
+ # Remove challenge-specific query param if present
2197
+ if hasattr(st, "query_params"):
2198
+ qp = st.query_params
2199
+ # st.query_params may be a Mapping; pop safely if supported
2200
+ try:
2201
+ if "game_id" in qp:
2202
+ qp.pop("game_id")
2203
+ except Exception:
2204
+ # Fallback: clear all params if pop not supported
2205
+ try:
2206
+ st.query_params.clear()
2207
+ except Exception:
2208
+ pass
2209
+ except Exception:
2210
+ pass
2211
+
2212
+ # Clear challenge session flags and links
2213
+ if st.session_state.get("loaded_game_sid") is not None:
2214
+ st.session_state.loaded_game_sid = None
2215
+ # Remove loaded challenge settings so UI no longer treats session as challenge mode
2216
+ st.session_state.pop("shared_game_settings", None)
2217
+ # Ensure the loader won't auto-reload challenge on rerun within this session
2218
+ st.session_state["shared_game_loaded"] = True
2219
+
2220
+ # Clear any existing generated share link tied to the previous challenge
2221
+ st.session_state.pop("share_url", None)
2222
+ st.session_state.pop("share_sid", None)
2223
+
2224
+ # Start a fresh game with updated options
2225
+ _new_game()
2226
+
2227
+ def _on_wordlist_change() -> None:
2228
+ """
2229
+ Callback when wordlist selection changes.
2230
+ Updates session state flags for AI vs file mode.
2231
+ """
2232
+ selected = st.session_state.get("wordlist_selector")
2233
+
2234
+ if selected == "AI Generated":
2235
+ st.session_state.use_ai_wordlist = True
2236
+ # Preserve current AI topic or use default
2237
+ if "ai_topic" not in st.session_state:
2238
+ st.session_state.ai_topic = "English"
2239
+ else:
2240
+ st.session_state.use_ai_wordlist = False
2241
+ st.session_state.selected_wordlist = selected
2242
+
2243
+ # Trigger new game with updated wordlist
2244
+ _on_game_option_change()
2245
+
2246
+ def _render_footer(current_page: str = "play"):
2247
+ """Render footer with navigation links to leaderboards and main game.
2248
+
2249
+ Args:
2250
+ current_page: Which page is currently active ("play", "daily", "weekly")
2251
+ """
2252
+ # Determine which link should be highlighted as active
2253
+ play_active = "active" if current_page == "play" else ""
2254
+ daily_active = "active" if current_page == "daily" else ""
2255
+ weekly_active = "active" if current_page == "weekly" else ""
2256
+
2257
+ # Check if we're in challenge mode and need to preserve game_id
2258
+ game_id = None
2259
+ try:
2260
+ params = st.query_params
2261
+ if "game_id" in params:
2262
+ game_id = params.get("game_id")
2263
+ except Exception:
2264
+ pass
2265
+
2266
+ # Also check session state for loaded challenge
2267
+ if not game_id and st.session_state.get("loaded_game_sid"):
2268
+ game_id = st.session_state.get("loaded_game_sid")
2269
+
2270
+ # Build URLs with game_id if in challenge mode
2271
+ if game_id:
2272
+ daily_url = f"?page=daily&game_id={game_id}"
2273
+ weekly_url = f"?page=weekly&game_id={game_id}"
2274
+ play_url = f"?game_id={game_id}"
2275
+ else:
2276
+ daily_url = "?page=daily"
2277
+ weekly_url = "?page=weekly"
2278
+ play_url = "/"
2279
+
2280
+ st.markdown(
2281
+ f"""
2282
+ <style>
2283
+ .bw-footer {{
2284
+ position: fixed;
2285
+ bottom: 0;
2286
+ left: 0;
2287
+ right: 0;
2288
+ background: linear-gradient(180deg, transparent 0%, rgba(11, 42, 74, 0.95) 30%, rgba(11, 42, 74, 0.98) 100%);
2289
+ padding: 0.75rem 1rem 0.5rem;
2290
+ z-index: 9998;
2291
+ text-align: center;
2292
+ }}
2293
+ .bw-footer-nav {{
2294
+ display: flex;
2295
+ justify-content: center;
2296
+ align-items: center;
2297
+ gap: 2rem;
2298
+ flex-wrap: wrap;
2299
+ }}
2300
+ .bw-footer-nav a {{
2301
+ color: #d7faff;
2302
+ text-decoration: none;
2303
+ font-weight: 600;
2304
+ font-size: 0.9rem;
2305
+ padding: 0.5rem 1rem;
2306
+ border-radius: 0.5rem;
2307
+ background: rgba(29, 100, 200, 0.3);
2308
+ border: 1px solid rgba(215, 250, 255, 0.3);
2309
+ transition: all 0.2s ease;
2310
+ }}
2311
+ .bw-footer-nav a:hover {{
2312
+ background: rgba(29, 100, 200, 0.6);
2313
+ border-color: rgba(215, 250, 255, 0.6);
2314
+ color: #ffffff;
2315
+ text-decoration: none;
2316
+ }}
2317
+ .bw-footer-nav a.active {{
2318
+ background: rgba(32, 212, 108, 0.3);
2319
+ border-color: rgba(32, 212, 108, 0.5);
2320
+ }}
2321
+ /* Add padding to main content to prevent footer overlap */
2322
+ .stMainBlockContainer {{
2323
+ padding-bottom: 70px !important;
2324
+ }}
2325
+ @media (max-width: 640px) {{
2326
+ .bw-footer-nav {{
2327
+ gap: 0.75rem;
2328
+ }}
2329
+ .bw-footer-nav a {{
2330
+ font-size: 0.8rem;
2331
+ padding: 0.4rem 0.75rem;
2332
+ }}
2333
+ }}
2334
+ </style>
2335
+ <div class="bw-footer">
2336
+ <nav class="bw-footer-nav">
2337
+ <a href="{daily_url}" title="View Daily Leaderboards" target="_self" class="{daily_active}">📅 Daily Leaderboards</a>
2338
+ <a href="{weekly_url}" title="View Weekly Leaderboards" target="_self" class="{weekly_active}">📆 Weekly Leaderboards</a>
2339
+ <a href="{play_url}" title="Play Wrdler" target="_self" class="{play_active}">🎮 Play</a>
2340
+ </nav>
2341
+ </div>
2342
+ """,
2343
+ unsafe_allow_html=True,
2344
+ )
2345
+
2346
  def run_app():
2347
  # Render PWA service worker registration (meta tags in <head> via Docker)
2348
  st.markdown(pwa_service_worker, unsafe_allow_html=True)
 
2362
  pass
2363
  st.session_state["hide_gameover_overlay"] = True
2364
 
2365
+ # Handle page navigation via query params
2366
+ page = params.get("page", "")
2367
+ if page in ["daily", "weekly"]:
2368
+ # Render leaderboard page with ocean background
2369
+ from .leaderboard_page import render_leaderboard_page
2370
+ st.markdown(ocean_background_css, unsafe_allow_html=True)
2371
+ inject_ocean_layers()
2372
+ render_leaderboard_page(default_tab=page)
2373
+ _render_footer(current_page=page)
2374
+ return # Don't render game UI
2375
+
2376
  # Handle game_id for loading shared games
2377
  if "game_id" in params and "shared_game_loaded" not in st.session_state:
2378
  sid = params.get("game_id")
 
2441
  state = _to_state()
2442
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
2443
  _render_game_over(state)
2444
+ _render_footer(current_page="play")