Add saved starter words for signed-in players

Players can save up to 3 favorite opening words and tap one to fill the
current row. Words are stored on profiles.starter_words and saved through
the set_starter_words RPC, which validates them against the dictionary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zakaria
2026-10-28 10:39:32 -04:00
parent e4d0d3b55c
commit 6daf3237e8
5 changed files with 427 additions and 4 deletions
+181
View File
@@ -20,9 +20,14 @@ let lastDefinition = null
let introDismissed = false
let pendingInteractionStart = false
let introTimers = []
let starterWords = []
let starterWordsLoaded = false
let starterWordsUnavailable = false
let isSavingStarterWords = false
const WORD_LENGTH = 5
const MAX_GUESSES = 6
const MAX_STARTER_WORDS = 3
const FLIP_ANIMATION_DURATION = 500
const DANCE_ANIMATION_DURATION = 500
const STATS_KEY = "fancy-wordle-stats-v2"
@@ -132,6 +137,14 @@ const introOverlay = document.getElementById("intro-overlay")
const introSkipButton = document.getElementById("intro-skip")
const introTileRow = document.getElementById("intro-tiles")
const introTiles = Array.from(document.querySelectorAll(".intro-tile"))
const starterPanel = document.getElementById("starter-panel")
const starterChips = document.getElementById("starter-chips")
const starterEditButton = document.getElementById("starter-edit")
const starterEditor = document.getElementById("starter-editor")
const starterInputs = Array.from(document.querySelectorAll(".starter-input"))
const starterCancelButton = document.getElementById("starter-cancel")
const starterSaveButton = document.getElementById("starter-save")
const starterHint = document.getElementById("starter-hint")
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeGame, { once: true })
@@ -372,10 +385,17 @@ async function initializeAuth() {
authSession = session
authProfile = session ? await getAuthProfile() : null
updateAuthControls()
if (session) loadStarterWords()
client.auth.onAuthStateChange((event, session) => {
const userChanged = authSession?.user?.id !== session?.user?.id
authSession = session
authProfile = null
if (userChanged) {
starterWords = []
starterWordsLoaded = false
starterWordsUnavailable = false
}
updateAuthControls()
if (event === "PASSWORD_RECOVERY") {
@@ -389,6 +409,7 @@ async function initializeAuth() {
authProfile = profile
updateAuthControls()
})
if (userChanged) loadStarterWords()
}
})
} catch (error) {
@@ -445,6 +466,141 @@ function updateAuthControls() {
if (authForm) authForm.hidden = !hasSupabase || Boolean(authSession)
if (authGuestButton) authGuestButton.hidden = Boolean(authSession)
if (authSignOutButton) authSignOutButton.hidden = !authSession
renderStarterPanel()
}
async function loadStarterWords() {
const client = getSupabaseClient()
if (!client || !authSession) return
const { data, error } = await client
.from("profiles")
.select("starter_words")
.eq("id", authSession.user.id)
.maybeSingle()
if (error) {
console.warn("Failed to load starter words:", error)
starterWordsUnavailable = true
renderStarterPanel()
return
}
starterWords = normalizeStarterWords(data?.starter_words)
starterWordsLoaded = true
starterWordsUnavailable = false
renderStarterPanel()
}
function normalizeStarterWords(words) {
if (!Array.isArray(words)) return []
return [...new Set(words.map(word => String(word || "").trim().toLowerCase()))]
.filter(word => /^[a-z]{5}$/.test(word))
.slice(0, MAX_STARTER_WORDS)
}
function renderStarterPanel() {
if (!starterPanel) return
starterPanel.hidden = !getSupabaseClient()
if (starterPanel.hidden) return
if (!authSession) {
setStarterEditing(false)
starterEditButton.hidden = true
starterChips.innerHTML = ""
starterHint.innerHTML = `<button type="button" class="link-button" data-starter-sign-in>Sign in</button> to save up to ${MAX_STARTER_WORDS} favorite openers.`
return
}
starterEditButton.hidden = !starterWordsLoaded || !starterEditor.hidden
starterChips.innerHTML = starterWords.map(word => `
<button type="button" class="starter-chip" data-starter-word="${escapeHtml(word)}" aria-label="Play starter word ${escapeHtml(word)}">${escapeHtml(word)}</button>
`).join("")
if (!starterEditor.hidden) return
starterHint.textContent = starterWordsUnavailable
? "Starter words are unavailable right now."
: !starterWordsLoaded
? "Loading your starter words…"
: starterWords.length
? "Tap a word to drop it into your current row."
: `Save up to ${MAX_STARTER_WORDS} favorite openers, like plane or arise.`
}
function setStarterEditing(isEditing) {
if (!starterEditor) return
starterEditor.hidden = !isEditing
starterChips.hidden = isEditing
starterEditButton.hidden = isEditing
if (isEditing) {
starterInputs.forEach((input, index) => {
input.value = starterWords[index] || ""
input.removeAttribute("aria-invalid")
})
starterHint.textContent = "Five-letter words from the guess list."
starterInputs[0]?.focus()
}
}
function useStarterWord(word) {
if (!hourlyRound || gameFinished || isAnimating || !introDismissed) return
if (!/^[a-z]{5}$/.test(word)) return
while (currentTileIndex > 0) deleteKey()
word.split("").forEach(pressKey)
}
async function saveStarterWords(event) {
event.preventDefault()
if (isSavingStarterWords) return
const client = getSupabaseClient()
if (!client || !authSession) return
const words = starterInputs.map(input => input.value.trim().toLowerCase())
let firstInvalid = null
starterInputs.forEach((input, index) => {
const word = words[index]
const isInvalid = word !== "" && (!/^[a-z]{5}$/.test(word) || (wordListMode === "full" && !dictionarySet.has(word)))
input.toggleAttribute("aria-invalid", isInvalid)
if (isInvalid && !firstInvalid) firstInvalid = input
})
if (firstInvalid) {
starterHint.textContent = `"${firstInvalid.value.trim().toUpperCase()}" is not in the word list.`
firstInvalid.focus()
return
}
isSavingStarterWords = true
starterSaveButton.disabled = true
starterHint.textContent = "Saving…"
const { data, error } = await client.rpc("set_starter_words", { words: words.filter(Boolean) })
isSavingStarterWords = false
starterSaveButton.disabled = false
if (error) {
console.warn("Failed to save starter words:", error)
starterHint.textContent = error.message === "Not in word list"
? "One of those words is not in the word list."
: "Could not save starter words. Try again."
return
}
starterWords = normalizeStarterWords(data)
starterWordsLoaded = true
setStarterEditing(false)
renderStarterPanel()
showAlert(starterWords.length ? "Starter words saved" : "Starter words cleared")
}
function getAuthLabel() {
@@ -725,6 +881,30 @@ function bindControls() {
})
authGuestButton?.addEventListener("click", () => authModal.close())
authSignOutButton?.addEventListener("click", signOut)
starterEditButton?.addEventListener("click", () => setStarterEditing(true))
starterCancelButton?.addEventListener("click", () => {
setStarterEditing(false)
renderStarterPanel()
})
starterEditor?.addEventListener("submit", saveStarterWords)
starterInputs.forEach(input => {
input.addEventListener("input", () => {
input.value = input.value.replace(/[^a-z]/gi, "").toLowerCase()
input.removeAttribute("aria-invalid")
})
})
starterPanel?.addEventListener("click", event => {
if (event.target.closest("[data-starter-sign-in]")) {
openAuth()
return
}
const chip = event.target.closest("[data-starter-word]")
if (!chip) return
chip.blur()
useStarterWord(chip.dataset.starterWord)
})
resetStatsButton.addEventListener("click", resetStats)
shareResultsButton.addEventListener("click", shareResult)
document.querySelector(".brand").addEventListener("click", event => {
@@ -951,6 +1131,7 @@ function handleMouseClick(event) {
function handleKeyPress(event) {
if (statsModal.open || authModal?.open) return
if (event.target.closest?.("input, textarea, select")) return
if (event.key === "Enter") {
submitGuess()