silly little BIBDLE title animation

This commit is contained in:
George Powell
2025-12-23 00:32:22 -05:00
parent ca60002dd9
commit 2e7cc1fa54
4 changed files with 550 additions and 466 deletions

View File

@@ -0,0 +1,83 @@
<script lang="ts">
let hovered = $state(false);
let isTapped = $state(false);
let tapMode = $state(false);
function handleMouseEnter() {
if (!tapMode) {
hovered = true;
}
}
function handleMouseLeave() {
hovered = false;
}
function handleTap() {
tapMode = true;
isTapped = !isTapped;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleTap();
}
}
let showExpanded = $derived(tapMode ? isTapped : hovered);
</script>
<div
class="title-container relative inline-block cursor-pointer"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
onclick={handleTap}
onkeydown={handleKeydown}
role="button"
tabindex="0"
>
<!-- BIBDLE (collapsed state) -->
<span
class="title-word absolute inset-0 text-center transition-all duration-500 ease-in-out"
class:opacity-0={showExpanded}
class:opacity-100={!showExpanded}
>
BIBDLE
</span>
<!-- BIBLE DAILY (expanded state) -->
<div
class="title-expanded flex flex-row items-center justify-center transition-all duration-500 ease-in-out"
class:opacity-0={!showExpanded}
class:opacity-100={showExpanded}
>
<span
class="transition-all duration-500 ease-in-out"
class:translate-x-0={!showExpanded}
class:-translate-x-4={showExpanded}>BIBLE</span
>
<span
class="transition-all duration-500 ease-in-out"
class:translate-x-0={!showExpanded}
class:translate-x-4={showExpanded}>DAILY</span
>
</div>
</div>
<style>
.title-container {
min-height: 1.5em;
}
.title-word,
.title-expanded {
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.2em;
}
.title-expanded span {
display: inline-block;
}
</style>

View File

@@ -1,150 +1,149 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { getBookById, toOrdinal } from "$lib/utils/game"; import { getBookById, toOrdinal } from "$lib/utils/game";
interface StatsData { interface StatsData {
solveRank: number; solveRank: number;
guessRank: number; guessRank: number;
totalSolves: number; totalSolves: number;
averageGuesses: number; averageGuesses: number;
} }
interface WeightedMessage { interface WeightedMessage {
text: string; text: string;
weight: number; weight: number;
} }
let { let {
grade, grade,
statsData, statsData,
correctBookId, correctBookId,
handleShare, handleShare,
copyToClipboard, copyToClipboard,
copied = $bindable(false), copied = $bindable(false),
statsSubmitted, statsSubmitted,
guessCount, guessCount,
} = $props(); } = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? ""); let bookName = $derived(getBookById(correctBookId)?.name ?? "");
let hasWebShare = $derived( let hasWebShare = $derived(
typeof navigator !== "undefined" && "share" in navigator, typeof navigator !== "undefined" && "share" in navigator
); );
let copySuccess = $state(false); let copySuccess = $state(false);
// List of congratulations messages with weights // List of congratulations messages with weights
const congratulationsMessages: WeightedMessage[] = [ const congratulationsMessages: WeightedMessage[] = [
{ text: "🎉 Congratulations! 🎉", weight: 1000 }, { text: "🎉 Congratulations! 🎉", weight: 1000 },
{ text: "⭐ You got it! ⭐", weight: 10 }, { text: "⭐ You got it! ⭐", weight: 10 },
{ text: "🎉 Yup 🎉", weight: 5 }, { text: "🎉 Yup 🎉", weight: 5 },
{ text: "👍🏻 Very nice! 👍🏻", weight: 1 }, { text: "👍🏻 Very nice! 👍🏻", weight: 1 },
]; ];
// Function to select a random message based on weights // Function to select a random message based on weights
function getRandomCongratulationsMessage(): string { function getRandomCongratulationsMessage(): string {
// Special case for first try success // Special case for first try success
if (guessCount === 1) { if (guessCount === 1) {
const n = Math.random(); const n = Math.random();
if (n < 0.95) { if (n < 0.99) {
return "🤯 First try! 🤯"; return "🤯 First try! 🤯";
} else { } else {
return "‼️ Axios ‼️"; return "‼️ Axios ‼️";
} }
} }
const totalWeight = congratulationsMessages.reduce( const totalWeight = congratulationsMessages.reduce(
(sum, msg) => sum + msg.weight, (sum, msg) => sum + msg.weight,
0, 0
); );
let random = Math.random() * totalWeight; let random = Math.random() * totalWeight;
for (const message of congratulationsMessages) { for (const message of congratulationsMessages) {
random -= message.weight; random -= message.weight;
if (random <= 0) { if (random <= 0) {
return message.text; return message.text;
} }
} }
// Fallback to first message if something goes wrong // Fallback to first message if something goes wrong
return congratulationsMessages[0].text; return congratulationsMessages[0].text;
} }
// Generate the congratulations message // Generate the congratulations message
let congratulationsMessage = $derived(getRandomCongratulationsMessage()); let congratulationsMessage = $derived(getRandomCongratulationsMessage());
</script> </script>
<div <div
class="p-8 sm:p-12 w-full bg-linear-to-r from-green-400 to-green-600 text-white rounded-2xl shadow-2xl text-center" class="p-8 sm:p-12 w-full bg-linear-to-r from-green-400 to-green-600 text-white rounded-2xl shadow-2xl text-center"
> >
<h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg"> <h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg">
{congratulationsMessage} {congratulationsMessage}
</h2> </h2>
<p class="text-lg sm:text-xl md:text-2xl"> <p class="text-lg sm:text-xl md:text-2xl">
The verse is from <span The verse is from <span class="font-black text-xl sm:text-2xl md:text-3xl"
class="font-black text-xl sm:text-2xl md:text-3xl">{bookName}</span >{bookName}</span
> >
</p> </p>
<p <p
class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block" class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block"
> >
Your grade: {grade} Your grade: {grade}
</p> </p>
{#if hasWebShare} {#if hasWebShare}
<button <button
onclick={handleShare} onclick={handleShare}
data-umami-event="Share" data-umami-event="Share"
class="mt-4 text-2xl font-bold p-2 bg-white/20 hover:bg-white/30 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none" class="mt-4 text-2xl font-bold p-2 bg-white/20 hover:bg-white/30 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
> >
📤 Share 📤 Share
</button> </button>
<button <button
onclick={() => { onclick={() => {
copyToClipboard(); copyToClipboard();
copySuccess = true; copySuccess = true;
setTimeout(() => { setTimeout(() => {
copySuccess = false; copySuccess = false;
}, 3000); }, 3000);
}} }}
data-umami-event="Copy to Clipboard" data-umami-event="Copy to Clipboard"
class={`mt-4 text-2xl font-bold p-2 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${ class={`mt-4 text-2xl font-bold p-2 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
copySuccess copySuccess
? "bg-green-400/50 hover:bg-green-500/60" ? "bg-green-400/50 hover:bg-green-500/60"
: "bg-white/20 hover:bg-white/30" : "bg-white/20 hover:bg-white/30"
}`} }`}
> >
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"} {copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
</button> </button>
{:else} {:else}
<button <button
onclick={handleShare} onclick={handleShare}
data-umami-event="Share" data-umami-event="Share"
class={`mt-4 text-2xl font-bold p-2 ${ class={`mt-4 text-2xl font-bold p-2 ${
copied copied
? "bg-green-400/50 hover:bg-green-500/60" ? "bg-green-400/50 hover:bg-green-500/60"
: "bg-white/20 hover:bg-white/30" : "bg-white/20 hover:bg-white/30"
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`} } rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
> >
{copied ? "Copied to clipboard!" : "📤 Share"} {copied ? "Copied to clipboard!" : "📤 Share"}
</button> </button>
{/if} {/if}
<!-- Statistics Display --> <!-- Statistics Display -->
{#if statsData} {#if statsData}
<div class="mt-6 space-y-2 text-lg" in:fade={{ delay: 800 }}> <div class="mt-6 space-y-2 text-lg" in:fade={{ delay: 800 }}>
<p class="font-regular"> <p class="font-regular">
You were the {toOrdinal(statsData.solveRank)} person to solve today. You were the {toOrdinal(statsData.solveRank)} person to solve today.
</p> </p>
<p class="font-regular"> <p class="font-regular">
You rank <span class="">{toOrdinal(statsData.guessRank)}</span> in You rank <span class="">{toOrdinal(statsData.guessRank)}</span> in guesses.
guesses. </p>
</p> <p class="opacity-90">
<p class="opacity-90"> Average: <span class="font-semibold"
Average: <span class="font-semibold" >{Math.ceil(statsData.averageGuesses)}</span
>{Math.ceil(statsData.averageGuesses)}</span > guesses
> guesses </p>
</p> </div>
</div> {:else if !statsSubmitted}
{:else if !statsSubmitted} <div class="mt-6 text-sm opacity-80">Submitting stats...</div>
<div class="mt-6 text-sm opacity-80">Submitting stats...</div> {/if}
{/if}
</div> </div>

View File

@@ -1,384 +1,382 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible"; import { bibleBooks, type BibleBook } from "$lib/types/bible";
import type { PageProps } from "./$types"; import type { PageProps } from "./$types";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import VerseDisplay from "$lib/components/VerseDisplay.svelte"; import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte"; import SearchInput from "$lib/components/SearchInput.svelte";
import GuessesTable from "$lib/components/GuessesTable.svelte"; import GuessesTable from "$lib/components/GuessesTable.svelte";
import CountdownTimer from "$lib/components/CountdownTimer.svelte"; import CountdownTimer from "$lib/components/CountdownTimer.svelte";
import WinScreen from "$lib/components/WinScreen.svelte"; import WinScreen from "$lib/components/WinScreen.svelte";
import Feedback from "$lib/components/Feedback.svelte"; import Feedback from "$lib/components/Feedback.svelte";
import { getGrade } from "$lib/utils/game"; import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import { getGrade } from "$lib/utils/game";
interface Guess { interface Guess {
book: BibleBook; book: BibleBook;
testamentMatch: boolean; testamentMatch: boolean;
sectionMatch: boolean; sectionMatch: boolean;
adjacent: boolean; adjacent: boolean;
} }
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let dailyVerse = $derived(data.dailyVerse); let dailyVerse = $derived(data.dailyVerse);
let correctBookId = $derived(data.correctBookId); let correctBookId = $derived(data.correctBookId);
let guesses = $state<Guess[]>([]); let guesses = $state<Guess[]>([]);
let searchQuery = $state(""); let searchQuery = $state("");
let copied = $state(false); let copied = $state(false);
let isDev = $state(false); let isDev = $state(false);
let anonymousId = $state(""); let anonymousId = $state("");
let statsSubmitted = $state(false); let statsSubmitted = $state(false);
let statsData = $state<{ let statsData = $state<{
solveRank: number; solveRank: number;
guessRank: number; guessRank: number;
totalSolves: number; totalSolves: number;
averageGuesses: number; averageGuesses: number;
} | null>(null); } | null>(null);
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id))); let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId)); let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
let grade = $derived( let grade = $derived(
isWon isWon
? getGrade( ? getGrade(guesses.length, getBookById(correctBookId)?.popularity ?? 0)
guesses.length, : ""
getBookById(correctBookId)?.popularity ?? 0, );
)
: "",
);
function getBookById(id: string): BibleBook | undefined { function getBookById(id: string): BibleBook | undefined {
return bibleBooks.find((b) => b.id === id); return bibleBooks.find((b) => b.id === id);
} }
function isAdjacent(id1: string, id2: string): boolean { function isAdjacent(id1: string, id2: string): boolean {
const b1 = getBookById(id1); const b1 = getBookById(id1);
const b2 = getBookById(id2); const b2 = getBookById(id2);
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1); return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
} }
function submitGuess(bookId: string) { function submitGuess(bookId: string) {
if (guesses.some((g) => g.book.id === bookId)) return; if (guesses.some((g) => g.book.id === bookId)) return;
const book = getBookById(bookId); const book = getBookById(bookId);
if (!book) return; if (!book) return;
const correctBook = getBookById(correctBookId); const correctBook = getBookById(correctBookId);
if (!correctBook) return; if (!correctBook) return;
const testamentMatch = book.testament === correctBook.testament; const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section; const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(book.id, correctBookId); const adjacent = isAdjacent(book.id, correctBookId);
console.log( console.log(
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`, `Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`
); );
guesses = [ guesses = [
{ {
book, book,
testamentMatch, testamentMatch,
sectionMatch, sectionMatch,
adjacent, adjacent,
}, },
...guesses, ...guesses,
]; ];
searchQuery = ""; searchQuery = "";
} }
function generateUUID(): string { function generateUUID(): string {
// Try native randomUUID if available // Try native randomUUID if available
if (typeof window.crypto.randomUUID === "function") { if (typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID(); return window.crypto.randomUUID();
} }
// Fallback UUID v4 generator for older browsers // Fallback UUID v4 generator for older browsers
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = const r = window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0; const v = c === "x" ? r : (r & 0x3) | 0x8;
const v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16);
return v.toString(16); });
}); }
}
function getOrCreateAnonymousId(): string { function getOrCreateAnonymousId(): string {
if (!browser) return ""; if (!browser) return "";
const key = "bibdle-anonymous-id"; const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key); let id = localStorage.getItem(key);
if (!id) { if (!id) {
id = generateUUID(); id = generateUUID();
localStorage.setItem(key, id); localStorage.setItem(key, id);
} }
return id; return id;
} }
// Initialize anonymous ID // Initialize anonymous ID
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
anonymousId = getOrCreateAnonymousId(); anonymousId = getOrCreateAnonymousId();
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`; const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
statsSubmitted = localStorage.getItem(statsKey) === "true"; statsSubmitted = localStorage.getItem(statsKey) === "true";
}); });
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
isDev = window.location.host === "192.168.0.42:5174"; isDev = window.location.host === "192.168.0.42:5174";
}); });
// Load saved guesses // Load saved guesses
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
const key = `bibdle-guesses-${dailyVerse.date}`; const key = `bibdle-guesses-${dailyVerse.date}`;
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (saved) { if (saved) {
let savedIds: string[] = JSON.parse(saved); let savedIds: string[] = JSON.parse(saved);
savedIds = Array.from(new Set(savedIds)); savedIds = Array.from(new Set(savedIds));
guesses = savedIds.map((bookId: string) => { guesses = savedIds.map((bookId: string) => {
const book = getBookById(bookId)!; const book = getBookById(bookId)!;
const correctBook = getBookById(correctBookId)!; const correctBook = getBookById(correctBookId)!;
const testamentMatch = book.testament === correctBook.testament; const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section; const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(bookId, correctBookId); const adjacent = isAdjacent(bookId, correctBookId);
return { return {
book, book,
testamentMatch, testamentMatch,
sectionMatch, sectionMatch,
adjacent, adjacent,
}; };
}); });
} }
}); });
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
localStorage.setItem( localStorage.setItem(
`bibdle-guesses-${dailyVerse.date}`, `bibdle-guesses-${dailyVerse.date}`,
JSON.stringify(guesses.map((g) => g.book.id)), JSON.stringify(guesses.map((g) => g.book.id))
); );
}); });
// Auto-submit stats when user wins // Auto-submit stats when user wins
$effect(() => { $effect(() => {
console.log("Stats effect triggered:", { console.log("Stats effect triggered:", {
browser, browser,
isWon, isWon,
anonymousId, anonymousId,
statsSubmitted, statsSubmitted,
statsData, statsData,
}); });
if (!browser || !isWon || !anonymousId) { if (!browser || !isWon || !anonymousId) {
console.log("Basic conditions not met"); console.log("Basic conditions not met");
return; return;
} }
if (statsSubmitted && !statsData) { if (statsSubmitted && !statsData) {
console.log("Fetching existing stats..."); console.log("Fetching existing stats...");
(async () => { (async () => {
try { try {
const response = await fetch( const response = await fetch(
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`, `/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`
); );
const result = await response.json(); const result = await response.json();
console.log("Stats response:", result); console.log("Stats response:", result);
if (result.success && result.stats) { if (result.success && result.stats) {
console.log("Setting stats data:", result.stats); console.log("Setting stats data:", result.stats);
statsData = result.stats; statsData = result.stats;
localStorage.setItem( localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`, `bibdle-stats-submitted-${dailyVerse.date}`,
"true", "true"
); );
} else if (result.error) { } else if (result.error) {
console.error("Server error:", result.error); console.error("Server error:", result.error);
} else { } else {
console.error("Unexpected response format:", result); console.error("Unexpected response format:", result);
} }
} catch (err) { } catch (err) {
console.error("Stats fetch failed:", err); console.error("Stats fetch failed:", err);
} }
})(); })();
return; return;
} }
console.log("Submitting stats..."); console.log("Submitting stats...");
async function submitStats() { async function submitStats() {
try { try {
const payload = { const payload = {
anonymousId, anonymousId,
date: dailyVerse.date, date: dailyVerse.date,
guessCount: guesses.length, guessCount: guesses.length,
}; };
console.log("Sending POST request with:", payload); console.log("Sending POST request with:", payload);
const response = await fetch("/api/submit-completion", { const response = await fetch("/api/submit-completion", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const result = await response.json(); const result = await response.json();
console.log("Stats response:", result); console.log("Stats response:", result);
if (result.success && result.stats) { if (result.success && result.stats) {
console.log("Setting stats data:", result.stats); console.log("Setting stats data:", result.stats);
statsData = result.stats; statsData = result.stats;
statsSubmitted = true; statsSubmitted = true;
localStorage.setItem( localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`, `bibdle-stats-submitted-${dailyVerse.date}`,
"true", "true"
); );
} else if (result.error) { } else if (result.error) {
console.error("Server error:", result.error); console.error("Server error:", result.error);
} else { } else {
console.error("Unexpected response format:", result); console.error("Unexpected response format:", result);
} }
} catch (err) { } catch (err) {
console.error("Stats submission failed:", err); console.error("Stats submission failed:", err);
} }
} }
submitStats(); submitStats();
}); });
function generateShareText(): string { function generateShareText(): string {
const emojis = guesses const emojis = guesses
.slice() .slice()
.reverse() .reverse()
.map((guess) => { .map((guess) => {
if (guess.book.id === correctBookId) return "✅"; if (guess.book.id === correctBookId) return "✅";
if (guess.adjacent) return "‼️"; if (guess.adjacent) return "‼️";
if (guess.sectionMatch) return "🟩"; if (guess.sectionMatch) return "🟩";
if (guess.testamentMatch) return "🟧"; if (guess.testamentMatch) return "🟧";
return "🟥"; return "🟥";
}) })
.join(""); .join("");
const dateFormatter = new Intl.DateTimeFormat("en-US", { const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}); });
const formattedDate = dateFormatter.format( const formattedDate = dateFormatter.format(
new Date(`${dailyVerse.date}T00:00:00`), new Date(`${dailyVerse.date}T00:00:00`)
); );
const siteUrl = window.location.origin; const siteUrl = window.location.origin;
return [ return [
`📖 Bibdle | ${formattedDate} 📖`, `📖 Bibdle | ${formattedDate} 📖`,
`${grade} (${guesses.length} guesses)`, `${grade} (${guesses.length} guesses)`,
`${emojis}\n`, `${emojis}\n`,
siteUrl, siteUrl,
].join("\n"); ].join("\n");
} }
async function share() { async function share() {
if (!browser) return; if (!browser) return;
const shareText = generateShareText(); const shareText = generateShareText();
try { try {
if ("share" in navigator) { if ("share" in navigator) {
await (navigator as any).share({ text: shareText }); await (navigator as any).share({ text: shareText });
} else { } else {
await (navigator as any).clipboard.writeText(shareText); await (navigator as any).clipboard.writeText(shareText);
} }
} catch (err) { } catch (err) {
console.error("Share failed:", err); console.error("Share failed:", err);
throw err; throw err;
} }
} }
async function copyToClipboard() { async function copyToClipboard() {
if (!browser) return; if (!browser) return;
const shareText = generateShareText(); const shareText = generateShareText();
try { try {
await (navigator as any).clipboard.writeText(shareText); await (navigator as any).clipboard.writeText(shareText);
copied = true; copied = true;
setTimeout(() => { setTimeout(() => {
copied = false; copied = false;
}, 5000); }, 5000);
} catch (err) { } catch (err) {
console.error("Copy to clipboard failed:", err); console.error("Copy to clipboard failed:", err);
throw err; throw err;
} }
} }
function handleShare() { function handleShare() {
if (copied || !browser) return; if (copied || !browser) return;
const useClipboard = !("share" in navigator); const useClipboard = !("share" in navigator);
if (useClipboard) { if (useClipboard) {
copied = true; copied = true;
} }
share() share()
.then(() => { .then(() => {
if (useClipboard) { if (useClipboard) {
setTimeout(() => { setTimeout(() => {
copied = false; copied = false;
}, 5000); }, 5000);
} }
}) })
.catch(() => { .catch(() => {
if (useClipboard) { if (useClipboard) {
copied = false; copied = false;
} }
}); });
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Bibdle A daily bible game{isDev ? " (dev)" : ""}</title> <title>Bibdle A daily bible game{isDev ? " (dev)" : ""}</title>
<meta <meta
name="description" name="description"
content="Guess which book of the Bible a daily verse comes from. A Wordle-inspired Bible game!" content="Guess which book of the Bible a daily verse comes from. A Wordle-inspired Bible game!"
/> />
</svelte:head> </svelte:head>
<div class="min-h-dvh bg-linear-to-br from-blue-50 to-indigo-100 py-8"> <div class="min-h-dvh bg-linear-to-br from-blue-50 to-indigo-100 py-8">
<div <div
class="pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] w-full max-w-3xl mx-auto px-4" class="pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] w-full max-w-3xl mx-auto px-4"
> >
<h1 <h1
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-8 sm:p-12" class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-8 sm:p-12"
> >
Bibdle <span class="font-normal">{isDev ? "dev" : ""}</span> <TitleAnimation />
</h1> <span class="font-normal">{isDev ? "dev" : ""}</span>
</h1>
<VerseDisplay {data} {isWon} /> <VerseDisplay {data} {isWon} />
{#if !isWon} {#if !isWon}
<SearchInput bind:searchQuery {guessedIds} {submitGuess} /> <SearchInput bind:searchQuery {guessedIds} {submitGuess} />
{:else} {:else}
<WinScreen <WinScreen
{grade} {grade}
{statsData} {statsData}
{correctBookId} {correctBookId}
{handleShare} {handleShare}
{copyToClipboard} {copyToClipboard}
bind:copied bind:copied
{statsSubmitted} {statsSubmitted}
guessCount={guesses.length} guessCount={guesses.length}
/> />
<CountdownTimer /> <CountdownTimer />
{/if} {/if}
<GuessesTable {guesses} {correctBookId} /> <GuessesTable {guesses} {correctBookId} />
{#if isWon} {#if isWon}
<Feedback /> <Feedback />
{/if} {/if}
</div> </div>
</div> </div>

26
todo.md
View File

@@ -8,20 +8,20 @@
- maybe remove rank and average guesses - maybe remove rank and average guesses
- only show "top 10 / 5 / 1%" if there are more than 10/20/100 guesses? - only show "top 10 / 5 / 1%" if there are more than 10/20/100 guesses?
- hovering or tapping BIBDLE fades in and out to BIBLE DAILY
- Difficulty levels - Difficulty levels
- difficult mode (guess old or new testament, first try *only*) - difficult mode (guess old or new testament, first try _only_)
- impossible mode (1894 scrivener koine greek NT or some hebrew version for OT) three guesses only - impossible mode (1894 scrivener koine greek NT or some hebrew version for OT) three guesses only
- "login to see your stats, unlock practice mode, and more"
# bibdle unlimited # bibdle unlimited
- Practice mode: Unlimited verses - Practice mode: Unlimited verses
- Create public or private leaderboards - Create public or private leaderboards
- Passport book with badges: - Passport book with badges:
- Guess each Gospel first try - Guess each Gospel first try
- "Guessed all Gospels", "Perfect week", "Old Testament expert" - "Guessed all Gospels", "Perfect week", "Old Testament expert"
- Theologian: Guess each book first try - Theologian: Guess each book first try
# places to send # places to send
@@ -33,25 +33,29 @@
As a young camper at the Metropolis of Boston Camp, I remember His Eminence Metropolitan Methodios would visit every Sunday. He was often surrounded by important people for his entire time there, so I never gathered the courage to introduce myself, but his homilies during Liturgy always stood out to me. In some ways, they differed year after year, but a majority of his message remained strikingly familiar. "Take ten minutes to read the Bible every day," he asked. "Just ten minutes. Go somewhere quiet, turn off the TV (then iPod, then cell phone), and read in peace and quiet." As a young camper at the Metropolis of Boston Camp, I remember His Eminence Metropolitan Methodios would visit every Sunday. He was often surrounded by important people for his entire time there, so I never gathered the courage to introduce myself, but his homilies during Liturgy always stood out to me. In some ways, they differed year after year, but a majority of his message remained strikingly familiar. "Take ten minutes to read the Bible every day," he asked. "Just ten minutes. Go somewhere quiet, turn off the TV (then iPod, then cell phone), and read in peace and quiet."
Despite His Eminence's otherwise cool and intimidating aura, it never came across as a demand or an order. Yet it wasn't exactly polite either. It sounded closer to pleading... like to his core, he knew how important it was, what he was asking — how important it would be for our lives. Despite His Eminence's otherwise cool and intimidating aura, it never came across as a demand or an order. Yet it wasn't exactly polite either. It sounded closer to pleading... like to his core, he knew how important it was, what he was asking — how important it would be for our lives.
I never really followed through with what he asked. The Metropolis of Boston Camp was my true home throughout my childhood, teenage years, and young adulthood. Leaving it every summer, and bringing the lessons, experiences, and faith I'd gained over the weeks and years home to the monotony of home remains the challenge of my life so far. I never really followed through with what he asked. The Metropolis of Boston Camp was my true home throughout my childhood, teenage years, and young adulthood. Leaving it every summer, and bringing the lessons, experiences, and faith I'd gained over the weeks and years home to the monotony of home remains the challenge of my life so far.
I created Bibdle from a combination of two things. The first is my lifelong desire to create experiences that people love; to create experiences that bring people together. The second is my guilt for never reading the Bible at home like Metropolitan Methodios asked. I hope it helps you with this challenge as much as it's helped me! I created Bibdle from a combination of two things. The first is my lifelong desire to create experiences that people love; to create experiences that bring people together. The second is my guilt for never reading the Bible at home like Metropolitan Methodios asked. I hope it helps you with this challenge as much as it's helped me!
------------------------------ ---
# done # done
## december 22nd
- hovering or tapping BIBDLE fades in and out to BIBLE DAILY
## december 21st ## december 21st
- better guess emoji consistency (removed ambiguous red squares) - better guess emoji consistency (removed ambiguous red squares)
- HH:MM until next verse - HH:MM until next verse
- triodion font for verse (PT Serif) - triodion font for verse (PT Serif)
- custom verses for Christmas Eve and Christmas - custom verses for Christmas Eve and Christmas
## before december 19th ## before december 19th
- improve design (uniform column widths on desktop) - improve design (uniform column widths on desktop)
- moved to bibdle.com - moved to bibdle.com
- v2: avg guesses per bible verse updating daily (on completion: avg. guesses: 6) - v2: avg guesses per bible verse updating daily (on completion: avg. guesses: 6)
@@ -61,4 +65,4 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
- site title - site title
- deploy - deploy
------------------------------ ---