mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -04:00
Compare commits
7 Commits
bd36f29419
...
fc674d6008
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc674d6008 | ||
|
|
087a476df8 | ||
|
|
ba45cbdc37 | ||
|
|
1de436534c | ||
|
|
3bcd7ea266 | ||
|
|
7ecc84ffbc | ||
|
|
3d78353a90 |
@@ -1,29 +1,158 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props();
|
let {
|
||||||
|
searchQuery = $bindable(""),
|
||||||
|
guessedIds,
|
||||||
|
submitGuess,
|
||||||
|
guessCount = 0,
|
||||||
|
}: {
|
||||||
|
searchQuery: string;
|
||||||
|
guessedIds: SvelteSet<string>;
|
||||||
|
submitGuess: (id: string) => void;
|
||||||
|
guessCount: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let filteredBooks = $derived(
|
type DisplayMode = "simple" | "testament" | "sections";
|
||||||
|
|
||||||
|
const displayMode = $derived<DisplayMode>(
|
||||||
|
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredBooks = $derived(
|
||||||
bibleBooks.filter((book) =>
|
bibleBooks.filter((book) =>
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type SimpleGroup = { books: BibleBook[] };
|
||||||
|
|
||||||
|
type TestamentGroup = {
|
||||||
|
testament: Testament;
|
||||||
|
label: string;
|
||||||
|
books: BibleBook[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SectionGroup = {
|
||||||
|
testament: Testament;
|
||||||
|
testamentLabel: string;
|
||||||
|
showTestamentHeader: boolean;
|
||||||
|
section: BibleSection;
|
||||||
|
books: BibleBook[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||||
|
const sorted = [...filteredBooks].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
return { books: sorted };
|
||||||
|
});
|
||||||
|
|
||||||
|
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||||
|
const old = filteredBooks
|
||||||
|
.filter((b) => b.testament === "old")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const newT = filteredBooks
|
||||||
|
.filter((b) => b.testament === "new")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const groups: TestamentGroup[] = [];
|
||||||
|
if (old.length > 0) {
|
||||||
|
groups.push({ testament: "old", label: "Old Testament", books: old });
|
||||||
|
}
|
||||||
|
if (newT.length > 0) {
|
||||||
|
groups.push({ testament: "new", label: "New Testament", books: newT });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||||
|
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||||
|
const seenKeys: Record<string, true> = {};
|
||||||
|
const orderedPairs: { testament: Testament; section: BibleSection }[] = [];
|
||||||
|
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
const key = `${book.testament}:${book.section}`;
|
||||||
|
if (!seenKeys[key]) {
|
||||||
|
seenKeys[key] = true;
|
||||||
|
orderedPairs.push({ testament: book.testament, section: book.section });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: SectionGroup[] = [];
|
||||||
|
let lastTestament: Testament | null = null;
|
||||||
|
|
||||||
|
for (const pair of orderedPairs) {
|
||||||
|
const books = filteredBooks.filter(
|
||||||
|
(b) => b.testament === pair.testament && b.section === pair.section
|
||||||
|
);
|
||||||
|
if (books.length === 0) continue;
|
||||||
|
|
||||||
|
const showTestamentHeader = pair.testament !== lastTestament;
|
||||||
|
lastTestament = pair.testament;
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
testament: pair.testament,
|
||||||
|
testamentLabel:
|
||||||
|
pair.testament === "old" ? "Old Testament" : "New Testament",
|
||||||
|
showTestamentHeader,
|
||||||
|
section: pair.section,
|
||||||
|
books,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
// First book in display order for Enter key submission
|
||||||
|
const firstBookId = $derived.by<string | null>(() => {
|
||||||
|
if (filteredBooks.length === 0) return null;
|
||||||
|
if (displayMode === "simple") {
|
||||||
|
return simpleGroup.books[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (displayMode === "testament") {
|
||||||
|
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
if (e.key === "Enter" && firstBookId) {
|
||||||
submitGuess(filteredBooks[0].id);
|
submitGuess(firstBookId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showBanner = $derived(guessCount >= 3);
|
||||||
|
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if showBanner}
|
||||||
|
<div
|
||||||
|
class="mb-3 flex items-center gap-2 px-4 py-2 rounded-full text-xs font-medium border w-fit transition-all duration-300
|
||||||
|
{bannerIsIndigo
|
||||||
|
? 'bg-indigo-50 border-indigo-200 text-indigo-700'
|
||||||
|
: 'bg-amber-50 border-amber-200 text-amber-700'}"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" class="text-[10px] leading-none">✦</span>
|
||||||
|
{#if bannerIsIndigo}
|
||||||
|
Testament & section groups now visible
|
||||||
|
{:else}
|
||||||
|
Old & New Testament groups now visible
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg
|
<svg
|
||||||
class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -41,7 +170,7 @@
|
|||||||
/>
|
/>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<button
|
<button
|
||||||
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
onclick={() => (searchQuery = "")}
|
onclick={() => (searchQuery = "")}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
@@ -51,6 +180,7 @@
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -62,29 +192,120 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if searchQuery && filteredBooks.length > 0}
|
{#if searchQuery && filteredBooks.length > 0}
|
||||||
<ul
|
<ul
|
||||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
|
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
|
||||||
|
role="listbox"
|
||||||
>
|
>
|
||||||
{#each filteredBooks as book (book.id)}
|
{#if displayMode === "simple"}
|
||||||
<li>
|
{#each simpleGroup.books as book (book.id)}
|
||||||
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
<button
|
<button
|
||||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
{guessedIds.has(book.id)
|
||||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
onclick={() => submitGuess(book.id)}
|
onclick={() => submitGuess(book.id)}
|
||||||
|
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
class="font-semibold {guessedIds.has(book.id)
|
||||||
? 'line-through text-gray-500'
|
? 'line-through text-gray-400'
|
||||||
: ''}">{book.name}</span
|
: ''}"
|
||||||
>
|
|
||||||
<span class="ml-auto text-sm opacity-75"
|
|
||||||
>({book.testament.toUpperCase()})</span
|
|
||||||
>
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
{:else if displayMode === "testament"}
|
||||||
|
{#each testamentGroups as group (group.testament)}
|
||||||
|
<li role="presentation">
|
||||||
|
<div
|
||||||
|
class="px-5 py-2 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs font-semibold uppercase tracking-wider text-gray-400"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 h-px bg-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each group.books as book (book.id)}
|
||||||
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
|
<button
|
||||||
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
|
{guessedIds.has(book.id)
|
||||||
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
|
onclick={() => submitGuess(book.id)}
|
||||||
|
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-semibold {guessedIds.has(book.id)
|
||||||
|
? 'line-through text-gray-400'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||||
|
<li role="presentation">
|
||||||
|
{#if group.showTestamentHeader}
|
||||||
|
<div
|
||||||
|
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs font-bold uppercase tracking-wider text-gray-500"
|
||||||
|
>
|
||||||
|
{group.testamentLabel}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 h-px bg-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 border-b border-gray-100"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-medium uppercase tracking-wider text-gray-400"
|
||||||
|
>
|
||||||
|
{group.section}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 h-px bg-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each group.books as book (book.id)}
|
||||||
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
|
<button
|
||||||
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
|
{guessedIds.has(book.id)
|
||||||
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
|
onclick={() => submitGuess(book.id)}
|
||||||
|
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-semibold {guessedIds.has(book.id)
|
||||||
|
? 'line-through text-gray-400'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
{:else if searchQuery}
|
{:else if searchQuery}
|
||||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
||||||
|
|||||||
@@ -15,15 +15,16 @@
|
|||||||
? dailyVerse.reference
|
? dailyVerse.reference
|
||||||
.replace(/^Psalms /, "Psalm ")
|
.replace(/^Psalms /, "Psalm ")
|
||||||
.replace(/\s(\d+):/, " ?:")
|
.replace(/\s(\d+):/, " ?:")
|
||||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||||
);
|
);
|
||||||
let displayVerseText = $derived(
|
let displayVerseText = $derived(
|
||||||
dailyVerse.verseText
|
dailyVerse.verseText
|
||||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||||
.replace(/[,:;-—]$/, "...")
|
.replace(/[,:;-—]$/, "..."),
|
||||||
);
|
);
|
||||||
|
|
||||||
let showReference = $state(false);
|
let showReference = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
// Delay showing reference until GuessesTable animation completes
|
// Delay showing reference until GuessesTable animation completes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
|
|
||||||
// Check if user already won today (page reload case)
|
// Check if user already won today (page reload case)
|
||||||
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||||
const alreadyWonToday = browser && localStorage.getItem(winTrackedKey) === "true";
|
const alreadyWonToday =
|
||||||
|
browser && localStorage.getItem(winTrackedKey) === "true";
|
||||||
|
|
||||||
if (alreadyWonToday) {
|
if (alreadyWonToday) {
|
||||||
// User already won and is refreshing - show immediately
|
// User already won and is refreshing - show immediately
|
||||||
@@ -49,6 +51,16 @@
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function copyVerse() {
|
||||||
|
navigator.clipboard.writeText(displayVerseText).then(() => {
|
||||||
|
copied = true;
|
||||||
|
(window as any).rybbit?.event("Copy Verse");
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
|
<Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
|
||||||
@@ -68,6 +80,19 @@
|
|||||||
>
|
>
|
||||||
{displayReference}
|
{displayReference}
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
transition:fade={{ duration: 300 }}
|
||||||
|
class="flex justify-center mt-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={copyVerse}
|
||||||
|
data-umami-event="Copy Verse"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 text-xs big-text text-gray-600 bg-white/50 hover:bg-white/70 border border-gray-300 rounded-lg transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{copied ? "✅" : "📋"}
|
||||||
|
{copied ? "Copied!" : "Copy verse to clipboard"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade, fly } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
import { getBookById, toOrdinal } from "$lib/utils/game";
|
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
import CountdownTimer from "./CountdownTimer.svelte";
|
import CountdownTimer from "./CountdownTimer.svelte";
|
||||||
import ChapterGuess from "./ChapterGuess.svelte";
|
import ChapterGuess from "./ChapterGuess.svelte";
|
||||||
@@ -53,6 +52,7 @@
|
|||||||
typeof navigator !== "undefined" && "share" in navigator,
|
typeof navigator !== "undefined" && "share" in navigator,
|
||||||
);
|
);
|
||||||
let copySuccess = $state(false);
|
let copySuccess = $state(false);
|
||||||
|
let bubbleCopied = $state(false);
|
||||||
|
|
||||||
// List of congratulations messages with weights
|
// List of congratulations messages with weights
|
||||||
const congratulationsMessages: WeightedMessage[] = [
|
const congratulationsMessages: WeightedMessage[] = [
|
||||||
@@ -109,7 +109,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if streak > 1}
|
{#if streak > 1}
|
||||||
<p class="big-text text-orange-500! text-lg! my-4">
|
<div class="flex flex-col gap-4 my-4">
|
||||||
|
<p class="big-text text-orange-500! text-lg!">
|
||||||
🔥 {streak} days in a row!
|
🔥 {streak} days in a row!
|
||||||
</p>
|
</p>
|
||||||
{#if streak >= 7}
|
{#if streak >= 7}
|
||||||
@@ -118,10 +119,14 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if streakPercentile !== null}
|
{#if streakPercentile !== null}
|
||||||
<p class="text-sm mt-4 text-gray-700 font-triodion">
|
<p class="text-sm text-gray-700 font-triodion">
|
||||||
{streakPercentile <= 50 ? "Only " : ""}{streakPercentile}% of players have a streak of {streak} or greater.
|
{streakPercentile <= 50
|
||||||
|
? "Only "
|
||||||
|
: ""}{streakPercentile}% of players have a streak of {streak}
|
||||||
|
or greater.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
@@ -205,34 +210,65 @@
|
|||||||
|
|
||||||
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
||||||
<div class="big-text font-black! text-center">Share your result</div>
|
<div class="big-text font-black! text-center">Share your result</div>
|
||||||
<div class="share-buttons">
|
<div class="chat-window">
|
||||||
{#if hasWebShare}
|
<!-- Received bubble: primary action (share / copy) -->
|
||||||
<button
|
<div class="bubble-wrapper received-wrapper">
|
||||||
onclick={() => { (window as any).rybbit?.event("Share"); handleShare(); }}
|
|
||||||
data-umami-event="Share"
|
|
||||||
class="share-btn primary"
|
|
||||||
>
|
|
||||||
📤 Click to share
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
<button
|
||||||
|
class="bubble bubble-received"
|
||||||
|
class:success={copySuccess}
|
||||||
|
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
|
||||||
|
data-umami-event={hasWebShare
|
||||||
|
? "Share"
|
||||||
|
: "Copy to Clipboard"}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
if (hasWebShare) {
|
||||||
|
(window as any).rybbit?.event("Share");
|
||||||
|
handleShare();
|
||||||
|
} else {
|
||||||
(window as any).rybbit?.event("Copy to Clipboard");
|
(window as any).rybbit?.event("Copy to Clipboard");
|
||||||
copyToClipboard();
|
copyToClipboard();
|
||||||
copySuccess = true;
|
copySuccess = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copySuccess = false;
|
copySuccess = false;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
data-umami-event="Copy to Clipboard"
|
|
||||||
class={`share-btn primary ${copySuccess ? "success" : ""}`}
|
|
||||||
>
|
>
|
||||||
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
|
{#if hasWebShare}
|
||||||
|
📤 Tap here to share
|
||||||
|
{:else if copySuccess}
|
||||||
|
✅ Copied!
|
||||||
|
{:else}
|
||||||
|
📋 Copy to clipboard
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sent bubble: share text preview -->
|
||||||
|
<div class="bubble-wrapper">
|
||||||
|
<button
|
||||||
|
class="bubble bubble-sent"
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
data-umami-event="Copy to Clipboard"
|
||||||
|
onclick={() => {
|
||||||
|
(window as any).rybbit?.event("Copy to Clipboard");
|
||||||
|
copyToClipboard();
|
||||||
|
bubbleCopied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
bubbleCopied = false;
|
||||||
|
}, 2000);
|
||||||
|
}}>{shareText}</button
|
||||||
|
>
|
||||||
|
{#if hasWebShare}
|
||||||
|
<span class="copy-hint"
|
||||||
|
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="copy-hint"
|
||||||
|
>{bubbleCopied ? "copied!" : ""}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-window">
|
|
||||||
<p class="bubble">{shareText}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,14 +285,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in {
|
:global(.fade-in) {
|
||||||
animation: fadeIn 0.5s ease-out;
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Share card ── */
|
/* ── Share card ── */
|
||||||
.share-card {
|
.share-card {
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: oklch(94% 0.028 298.626);
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 1.25rem;
|
border-radius: 1.25rem;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
@@ -278,115 +313,167 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-card-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: #8e8e93;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Chat window ── */
|
/* ── Chat window ── */
|
||||||
.chat-window {
|
.chat-window {
|
||||||
--sent-color: #0b93f6;
|
--sent-color: #0b93f6;
|
||||||
--bg: oklch(93.996% 0.03041 300.209);
|
--received-color: #3a3a3c;
|
||||||
|
--bg: oklch(94% 0.028 298.626);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
padding: 0 1.5rem 0;
|
padding: 0 0.5rem 0;
|
||||||
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bubble (from article technique) ── */
|
/* ── Bubble wrappers ── */
|
||||||
|
.bubble-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-wrapper {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared bubble base ── */
|
||||||
.bubble {
|
.bubble {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 255px;
|
max-width: 255px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 0;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
transform: rotate(-2deg);
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
color: white;
|
filter 80ms ease,
|
||||||
background: var(--sent-color);
|
transform 80ms ease;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble::before,
|
/* ── Sent bubble (share text preview) ── */
|
||||||
.bubble::after {
|
.bubble-sent {
|
||||||
|
color: white;
|
||||||
|
background: var(--sent-color);
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:hover {
|
||||||
|
background-color: #2ea8ff;
|
||||||
|
transform: rotate(-2deg) translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:hover::before {
|
||||||
|
background-color: #2ea8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:active {
|
||||||
|
background-color: #0878d4;
|
||||||
|
transform: rotate(-2deg) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:active::before {
|
||||||
|
background-color: #0878d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sent tail: bottom-right */
|
||||||
|
.bubble-sent::before,
|
||||||
|
.bubble-sent::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble::before {
|
.bubble-sent::before {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
right: -7px;
|
right: -7px;
|
||||||
background-color: var(--sent-color);
|
background-color: var(--sent-color);
|
||||||
border-bottom-left-radius: 16px 14px;
|
border-bottom-left-radius: 16px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble::after {
|
.bubble-sent::after {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
right: -26px;
|
right: -26px;
|
||||||
border-bottom-left-radius: 10px;
|
border-bottom-left-radius: 10px;
|
||||||
background-color: var(--bg);
|
background-color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Share buttons ── */
|
/* ── Received bubble (action button) ── */
|
||||||
.share-buttons {
|
.bubble-received {
|
||||||
display: flex;
|
color: #f5f5f7;
|
||||||
flex-direction: column;
|
background: var(--received-color);
|
||||||
gap: 0.5rem;
|
transform: rotate(2deg);
|
||||||
width: 100%;
|
padding: 14px 24px;
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border: 3px solid rgba(0, 0, 0, 0.25);
|
min-width: 14rem;
|
||||||
border-radius: 1rem;
|
text-align: center;
|
||||||
cursor: pointer;
|
}
|
||||||
transition:
|
|
||||||
transform 80ms ease,
|
.bubble-received:hover {
|
||||||
box-shadow 80ms ease,
|
background-color: #4a4a4e;
|
||||||
opacity 80ms ease;
|
transform: rotate(2deg) translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:hover::before {
|
||||||
|
background-color: #4a4a4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:active {
|
||||||
|
background-color: #2a2a2c;
|
||||||
|
transform: rotate(2deg) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:active::before {
|
||||||
|
background-color: #2a2a2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received.success {
|
||||||
|
background: #c7f7d4;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Received tail: bottom-left (mirror of sent) */
|
||||||
|
.bubble-received::before,
|
||||||
|
.bubble-received::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
height: 25px;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received::before {
|
||||||
|
width: 20px;
|
||||||
|
left: -7px;
|
||||||
|
background-color: var(--received-color);
|
||||||
|
border-bottom-right-radius: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received::after {
|
||||||
|
width: 26px;
|
||||||
|
left: -26px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received.success::before {
|
||||||
|
background-color: #c7f7d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Copy hints ── */
|
||||||
|
.copy-hint {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #444;
|
||||||
|
font-weight: 400;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
padding-right: 32px;
|
||||||
|
transform: rotate(-2deg);
|
||||||
.share-btn:active {
|
transform-origin: right center;
|
||||||
transform: scale(0.97);
|
margin-top: -6px;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn.primary {
|
|
||||||
background: #7c3aed;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn.primary:hover {
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn.primary.success {
|
|
||||||
background: #636363;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn.secondary {
|
|
||||||
background: #f2f2f7;
|
|
||||||
color: #1c1c1e;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn.secondary:hover {
|
|
||||||
background: #e5e5ea;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
type StatsData,
|
type StatsData,
|
||||||
} from "$lib/utils/stats-client";
|
} from "$lib/utils/stats-client";
|
||||||
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let guessedIds = $derived(
|
let guessedIds = $derived(
|
||||||
new Set(persistence.guesses.map((g) => g.book.id)),
|
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentDate = $derived(
|
const currentDate = $derived(
|
||||||
@@ -302,7 +303,7 @@
|
|||||||
|
|
||||||
{#if !isWon}
|
{#if !isWon}
|
||||||
<div class="animate-fade-in-up animate-delay-400">
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<SearchInput bind:searchQuery {guessedIds} {submitGuess} guessCount={persistence.guesses.length} />
|
||||||
</div>
|
</div>
|
||||||
{:else if showWinScreen}
|
{:else if showWinScreen}
|
||||||
<div class="animate-fade-in-up animate-delay-400">
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
|
|||||||
45
todo.md
45
todo.md
@@ -59,10 +59,49 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
|
|
||||||
# done
|
# done
|
||||||
|
|
||||||
|
## february 22nd
|
||||||
|
|
||||||
|
- New share button design; speech bubbles
|
||||||
|
- Share rate jumped from ~17% to ~27% (n=200) after share button redesign
|
||||||
|
- Updated streak-percentile to count all players from last 30 days and all streaks (in case there are streaks >30 days)
|
||||||
|
- Added copy verse button
|
||||||
|
- Refactored book search input to show progressively more info based on guess count
|
||||||
|
|
||||||
|
## february 21st
|
||||||
|
|
||||||
|
- Added streak counter and streak percentage
|
||||||
|
- Added Rybbit analytics alongside Umami
|
||||||
|
|
||||||
|
## february 18th-19th
|
||||||
|
|
||||||
|
- Refactored game logic into utility modules
|
||||||
|
- Small fixes to Sign In with Apple migrations
|
||||||
|
|
||||||
|
## february 13th
|
||||||
|
|
||||||
|
- Added Sign In with Apple
|
||||||
|
- Added animations on win and guess
|
||||||
|
- Various Apple auth bug fixes
|
||||||
|
|
||||||
|
## february 11th-12th
|
||||||
|
|
||||||
|
- Client-side timezone handling for daily verses (was using server time)
|
||||||
|
- Staggered page load animations
|
||||||
|
- Reordered guesses table with emphasis
|
||||||
|
- Redesigned stats page with dark theme and enhanced statistics
|
||||||
|
|
||||||
|
## february 5th-10th
|
||||||
|
|
||||||
|
- Added login modal and auth infrastructure
|
||||||
|
- Switched to `bun:sqlite`
|
||||||
|
- Support authenticated users in stats and page loading
|
||||||
|
- Anonymous stats migration on sign-in
|
||||||
|
- Test infrastructure and sign-in migration tests
|
||||||
|
|
||||||
## february 2nd
|
## february 2nd
|
||||||
|
|
||||||
- created rss feed
|
- created rss feed
|
||||||
- fixed "first letter" clue edge cases
|
- fixed "first letter" clue edge cases / easter egg
|
||||||
- updated ranking formula
|
- updated ranking formula
|
||||||
|
|
||||||
## january 28th
|
## january 28th
|
||||||
@@ -82,6 +121,10 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
- added "first letter" column
|
- added "first letter" column
|
||||||
- added imposter mode, v0.1 (mom likes it) but needs work
|
- added imposter mode, v0.1 (mom likes it) but needs work
|
||||||
|
|
||||||
|
## january 8th
|
||||||
|
|
||||||
|
- posted on Hacker News and LinkedIn, got 960 visitors in one day
|
||||||
|
|
||||||
## january 5th
|
## january 5th
|
||||||
|
|
||||||
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
||||||
|
|||||||
Reference in New Issue
Block a user