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:
+125
-1
@@ -1015,6 +1015,123 @@ h2 {
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.starter-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 34rem;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: color-mix(in oklch, var(--surface), var(--bg) 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-panel[hidden],
|
||||||
|
.starter-editor[hidden],
|
||||||
|
.starter-chips[hidden],
|
||||||
|
.starter-panel .link-button[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-head .eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-chips:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-chip {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--tile);
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: transform 160ms ease, border-color 160ms ease, background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-chip:hover,
|
||||||
|
.starter-chip:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-chip:active {
|
||||||
|
background: var(--key);
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-input {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent), transparent 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-input[aria-invalid] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-hint .link-button {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes intro-tile-enter {
|
@keyframes intro-tile-enter {
|
||||||
0% {
|
0% {
|
||||||
transform: translate3d(0, 34px, -180px);
|
transform: translate3d(0, 34px, -180px);
|
||||||
@@ -1179,10 +1296,17 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
.deck {
|
.deck,
|
||||||
|
.starter-panel {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.starter-panel {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18px;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
.deck {
|
.deck {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-2
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="theme-color" content="#f7efe6">
|
<meta name="theme-color" content="#f7efe6">
|
||||||
<title>Fancy Wordle</title>
|
<title>Fancy Wordle</title>
|
||||||
<link rel="stylesheet" href="CSS/styles.css?v=9">
|
<link rel="stylesheet" href="CSS/styles.css?v=10">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
|
||||||
<script src="word-data.js?v=5" defer></script>
|
<script src="word-data.js?v=5" defer></script>
|
||||||
<script src="supabase-config.js?v=1" defer></script>
|
<script src="supabase-config.js?v=1" defer></script>
|
||||||
<script src="script.js?v=11" defer></script>
|
<script src="script.js?v=13" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="intro-active dark-theme">
|
<body class="intro-active dark-theme">
|
||||||
<section class="intro-overlay" id="intro-overlay" aria-label="Animated Wordle intro" role="dialog" aria-modal="true">
|
<section class="intro-overlay" id="intro-overlay" aria-label="Animated Wordle intro" role="dialog" aria-modal="true">
|
||||||
@@ -61,6 +61,26 @@
|
|||||||
<p class="eyebrow">Hourly challenge</p>
|
<p class="eyebrow">Hourly challenge</p>
|
||||||
<h1 id="game-title">Guess the word in six tries.</h1>
|
<h1 id="game-title">Guess the word in six tries.</h1>
|
||||||
<p class="deck">Play one five-letter word per hour. Valid words reveal with smooth flips, warm feedback, confetti, sound, and a definition at the end.</p>
|
<p class="deck">Play one five-letter word per hour. Valid words reveal with smooth flips, warm feedback, confetti, sound, and a definition at the end.</p>
|
||||||
|
|
||||||
|
<aside class="starter-panel" id="starter-panel" aria-labelledby="starter-title" hidden>
|
||||||
|
<div class="starter-head">
|
||||||
|
<p class="eyebrow" id="starter-title">Starter words</p>
|
||||||
|
<button type="button" class="link-button" id="starter-edit">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="starter-chips" id="starter-chips"></div>
|
||||||
|
<form class="starter-editor" id="starter-editor" hidden>
|
||||||
|
<div class="starter-inputs">
|
||||||
|
<input type="text" class="starter-input" aria-label="Starter word 1" maxlength="5" autocomplete="off" autocapitalize="characters" spellcheck="false">
|
||||||
|
<input type="text" class="starter-input" aria-label="Starter word 2" maxlength="5" autocomplete="off" autocapitalize="characters" spellcheck="false">
|
||||||
|
<input type="text" class="starter-input" aria-label="Starter word 3" maxlength="5" autocomplete="off" autocapitalize="characters" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="starter-editor-actions">
|
||||||
|
<button type="button" class="button secondary" id="starter-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="button primary" id="starter-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="starter-hint" id="starter-hint" aria-live="polite"></p>
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="board-panel" aria-label="Wordle game board">
|
<section class="board-panel" aria-label="Wordle game board">
|
||||||
|
|||||||
@@ -20,9 +20,14 @@ let lastDefinition = null
|
|||||||
let introDismissed = false
|
let introDismissed = false
|
||||||
let pendingInteractionStart = false
|
let pendingInteractionStart = false
|
||||||
let introTimers = []
|
let introTimers = []
|
||||||
|
let starterWords = []
|
||||||
|
let starterWordsLoaded = false
|
||||||
|
let starterWordsUnavailable = false
|
||||||
|
let isSavingStarterWords = false
|
||||||
|
|
||||||
const WORD_LENGTH = 5
|
const WORD_LENGTH = 5
|
||||||
const MAX_GUESSES = 6
|
const MAX_GUESSES = 6
|
||||||
|
const MAX_STARTER_WORDS = 3
|
||||||
const FLIP_ANIMATION_DURATION = 500
|
const FLIP_ANIMATION_DURATION = 500
|
||||||
const DANCE_ANIMATION_DURATION = 500
|
const DANCE_ANIMATION_DURATION = 500
|
||||||
const STATS_KEY = "fancy-wordle-stats-v2"
|
const STATS_KEY = "fancy-wordle-stats-v2"
|
||||||
@@ -132,6 +137,14 @@ const introOverlay = document.getElementById("intro-overlay")
|
|||||||
const introSkipButton = document.getElementById("intro-skip")
|
const introSkipButton = document.getElementById("intro-skip")
|
||||||
const introTileRow = document.getElementById("intro-tiles")
|
const introTileRow = document.getElementById("intro-tiles")
|
||||||
const introTiles = Array.from(document.querySelectorAll(".intro-tile"))
|
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") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", initializeGame, { once: true })
|
document.addEventListener("DOMContentLoaded", initializeGame, { once: true })
|
||||||
@@ -372,10 +385,17 @@ async function initializeAuth() {
|
|||||||
authSession = session
|
authSession = session
|
||||||
authProfile = session ? await getAuthProfile() : null
|
authProfile = session ? await getAuthProfile() : null
|
||||||
updateAuthControls()
|
updateAuthControls()
|
||||||
|
if (session) loadStarterWords()
|
||||||
|
|
||||||
client.auth.onAuthStateChange((event, session) => {
|
client.auth.onAuthStateChange((event, session) => {
|
||||||
|
const userChanged = authSession?.user?.id !== session?.user?.id
|
||||||
authSession = session
|
authSession = session
|
||||||
authProfile = null
|
authProfile = null
|
||||||
|
if (userChanged) {
|
||||||
|
starterWords = []
|
||||||
|
starterWordsLoaded = false
|
||||||
|
starterWordsUnavailable = false
|
||||||
|
}
|
||||||
updateAuthControls()
|
updateAuthControls()
|
||||||
|
|
||||||
if (event === "PASSWORD_RECOVERY") {
|
if (event === "PASSWORD_RECOVERY") {
|
||||||
@@ -389,6 +409,7 @@ async function initializeAuth() {
|
|||||||
authProfile = profile
|
authProfile = profile
|
||||||
updateAuthControls()
|
updateAuthControls()
|
||||||
})
|
})
|
||||||
|
if (userChanged) loadStarterWords()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -445,6 +466,141 @@ function updateAuthControls() {
|
|||||||
if (authForm) authForm.hidden = !hasSupabase || Boolean(authSession)
|
if (authForm) authForm.hidden = !hasSupabase || Boolean(authSession)
|
||||||
if (authGuestButton) authGuestButton.hidden = Boolean(authSession)
|
if (authGuestButton) authGuestButton.hidden = Boolean(authSession)
|
||||||
if (authSignOutButton) authSignOutButton.hidden = !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() {
|
function getAuthLabel() {
|
||||||
@@ -725,6 +881,30 @@ function bindControls() {
|
|||||||
})
|
})
|
||||||
authGuestButton?.addEventListener("click", () => authModal.close())
|
authGuestButton?.addEventListener("click", () => authModal.close())
|
||||||
authSignOutButton?.addEventListener("click", signOut)
|
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)
|
resetStatsButton.addEventListener("click", resetStats)
|
||||||
shareResultsButton.addEventListener("click", shareResult)
|
shareResultsButton.addEventListener("click", shareResult)
|
||||||
document.querySelector(".brand").addEventListener("click", event => {
|
document.querySelector(".brand").addEventListener("click", event => {
|
||||||
@@ -951,6 +1131,7 @@ function handleMouseClick(event) {
|
|||||||
|
|
||||||
function handleKeyPress(event) {
|
function handleKeyPress(event) {
|
||||||
if (statsModal.open || authModal?.open) return
|
if (statsModal.open || authModal?.open) return
|
||||||
|
if (event.target.closest?.("input, textarea, select")) return
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
submitGuess()
|
submitGuess()
|
||||||
|
|||||||
+54
-1
@@ -18,7 +18,15 @@ create table if not exists public.profiles (
|
|||||||
);
|
);
|
||||||
|
|
||||||
alter table public.profiles
|
alter table public.profiles
|
||||||
add column if not exists username text;
|
add column if not exists username text,
|
||||||
|
add column if not exists starter_words text[] not null default '{}';
|
||||||
|
|
||||||
|
alter table public.profiles
|
||||||
|
drop constraint if exists profiles_starter_words_limit;
|
||||||
|
|
||||||
|
alter table public.profiles
|
||||||
|
add constraint profiles_starter_words_limit
|
||||||
|
check (cardinality(starter_words) <= 3);
|
||||||
|
|
||||||
create unique index if not exists profiles_username_key
|
create unique index if not exists profiles_username_key
|
||||||
on public.profiles (lower(username))
|
on public.profiles (lower(username))
|
||||||
@@ -490,6 +498,50 @@ begin
|
|||||||
end;
|
end;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.set_starter_words(words text[])
|
||||||
|
returns text[]
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
normalized_words text[];
|
||||||
|
begin
|
||||||
|
if auth.uid() is null then
|
||||||
|
raise exception 'Sign in to save starter words';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coalesce(array_agg(word order by first_position), '{}')
|
||||||
|
into normalized_words
|
||||||
|
from (
|
||||||
|
select lower(trim(candidate)) as word, min(position) as first_position
|
||||||
|
from unnest(coalesce(words, '{}')) with ordinality as input(candidate, position)
|
||||||
|
where nullif(trim(candidate), '') is not null
|
||||||
|
group by lower(trim(candidate))
|
||||||
|
) deduped;
|
||||||
|
|
||||||
|
if cardinality(normalized_words) > 3 then
|
||||||
|
raise exception 'You can save up to 3 starter words';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if exists (
|
||||||
|
select 1
|
||||||
|
from unnest(normalized_words) as candidate(word)
|
||||||
|
where candidate.word !~ '^[a-z]{5}$'
|
||||||
|
or not exists (select 1 from public.wordle_dictionary dictionary where dictionary.word = candidate.word)
|
||||||
|
) then
|
||||||
|
raise exception 'Not in word list';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.profiles
|
||||||
|
set starter_words = normalized_words,
|
||||||
|
updated_at = now()
|
||||||
|
where id = auth.uid();
|
||||||
|
|
||||||
|
return normalized_words;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
create or replace function public.get_player_current_streak(target_user_id uuid)
|
create or replace function public.get_player_current_streak(target_user_id uuid)
|
||||||
returns integer
|
returns integer
|
||||||
language plpgsql
|
language plpgsql
|
||||||
@@ -743,6 +795,7 @@ grant select, update on public.profiles to authenticated;
|
|||||||
grant select on public.wordle_rounds to authenticated;
|
grant select on public.wordle_rounds to authenticated;
|
||||||
grant select on public.wordle_guesses to authenticated;
|
grant select on public.wordle_guesses to authenticated;
|
||||||
grant execute on function public.is_username_available(text) to anon, authenticated;
|
grant execute on function public.is_username_available(text) to anon, authenticated;
|
||||||
|
grant execute on function public.set_starter_words(text[]) to authenticated;
|
||||||
grant execute on function public.require_confirmed_email() to authenticated;
|
grant execute on function public.require_confirmed_email() to authenticated;
|
||||||
grant execute on function public.start_hourly_round() to authenticated;
|
grant execute on function public.start_hourly_round() to authenticated;
|
||||||
grant execute on function public.submit_guess(uuid, text) to authenticated;
|
grant execute on function public.submit_guess(uuid, text) to authenticated;
|
||||||
|
|||||||
@@ -116,3 +116,48 @@ test("definition lookup leaves loading state when a provider times out", async (
|
|||||||
timeout: 6500
|
timeout: 6500
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("guests are prompted to sign in to save starter words", async ({ page }) => {
|
||||||
|
await openApp(page)
|
||||||
|
await skipIntro(page)
|
||||||
|
|
||||||
|
const starterPanel = page.locator("#starter-panel")
|
||||||
|
await expect(starterPanel).toBeVisible()
|
||||||
|
await expect(starterPanel).toContainText("to save up to 3 favorite openers")
|
||||||
|
await expect(page.locator("#starter-edit")).toBeHidden()
|
||||||
|
|
||||||
|
await starterPanel.locator("[data-starter-sign-in]").click()
|
||||||
|
await expect(page.locator("#auth-modal")).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("starter words fill the current row and the editor keeps keys off the board", async ({ page }) => {
|
||||||
|
await page.route("**/rest/v1/rpc/set_starter_words", route => route.fulfill({ json: ["crane", "arise"] }))
|
||||||
|
await openApp(page)
|
||||||
|
await skipIntro(page)
|
||||||
|
await expect(page.locator("#round-status")).not.toContainText("Loading")
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
authSession = { user: { id: "test-user", email: "player@example.test" } }
|
||||||
|
starterWords = ["plane", "arise"]
|
||||||
|
starterWordsLoaded = true
|
||||||
|
renderStarterPanel()
|
||||||
|
})
|
||||||
|
|
||||||
|
const readFirstRow = () => page.locator("[data-guess-grid] .tile").evaluateAll(tiles =>
|
||||||
|
tiles.slice(0, 5).map(tile => tile.dataset.letter || "").join("")
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.keyboard.press("Q")
|
||||||
|
await page.locator("[data-starter-word='arise']").click()
|
||||||
|
expect(await readFirstRow()).toBe("arise")
|
||||||
|
|
||||||
|
await page.locator("#starter-edit").click()
|
||||||
|
const firstInput = page.locator(".starter-input").first()
|
||||||
|
await expect(firstInput).toHaveValue("plane")
|
||||||
|
await firstInput.fill("crane")
|
||||||
|
await firstInput.press("Enter")
|
||||||
|
|
||||||
|
await expect(page.locator("[data-alert-container]")).toContainText("Starter words saved")
|
||||||
|
await expect(page.locator("[data-starter-word]")).toHaveText(["crane", "arise"])
|
||||||
|
expect(await readFirstRow()).toBe("arise")
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user