mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-02-04 10:54:44 -05:00
feat: Add Imposter game component and update project assets
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -27,6 +27,5 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
llms-*
|
llms-*
|
||||||
|
|
||||||
engwebu_usfx.xml
|
embeddings*
|
||||||
embeddings-cache-L12.json
|
*.xml
|
||||||
embeddings-cache-L6.json
|
|
||||||
@@ -25056,7 +25056,7 @@
|
|||||||
<chapter number="3">
|
<chapter number="3">
|
||||||
<verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse>
|
<verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse>
|
||||||
<verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a refiner’s fire And like launderers’ soap.</verse>
|
<verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a refiner’s fire And like launderers’ soap.</verse>
|
||||||
<verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, And purge them as gold and silver, That they may offer to the LordAn offering in righteousness.</verse>
|
<verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, and purge them as gold and silver, that they may offer to the Lord an offering in righteousness.</verse>
|
||||||
<verse number="4">“Then the offering of Judah and Jerusalem Will be pleasant to the Lord, As in the days of old, As in former years.</verse>
|
<verse number="4">“Then the offering of Judah and Jerusalem Will be pleasant to the Lord, As in the days of old, As in former years.</verse>
|
||||||
<verse number="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse>
|
<verse number="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse>
|
||||||
<verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse>
|
<verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse>
|
||||||
|
|||||||
4
src/lib/assets/Bluesky_Logo.svg
Normal file
4
src/lib/assets/Bluesky_Logo.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="600" height="530" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" fill="#1185fe"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 745 B |
241
src/lib/components/Imposter.svelte
Normal file
241
src/lib/components/Imposter.svelte
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
interface ImposterData {
|
||||||
|
verses: string[];
|
||||||
|
refs: string[];
|
||||||
|
imposterIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: ImposterData | null = null;
|
||||||
|
let clicked: boolean[] = [];
|
||||||
|
let gameOver = false;
|
||||||
|
let loading = true;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
async function loadGame() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/imposter");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
data = (await res.json()) as ImposterData;
|
||||||
|
clicked = new Array(data.verses.length).fill(false);
|
||||||
|
gameOver = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : "Unknown error";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(index: number) {
|
||||||
|
if (gameOver || !data || clicked[index]) return;
|
||||||
|
clicked[index] = true;
|
||||||
|
if (index !== data.imposterIndex) {
|
||||||
|
clicked[data.imposterIndex] = true;
|
||||||
|
}
|
||||||
|
gameOver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newGame() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
data = null;
|
||||||
|
loadGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadGame);
|
||||||
|
|
||||||
|
function formatVerse(verse: string): string {
|
||||||
|
let formatted = verse;
|
||||||
|
|
||||||
|
// Handle unbalanced opening/closing punctuation
|
||||||
|
const pairs: [string, string][] = [
|
||||||
|
["(", ")"],
|
||||||
|
["[", "]"],
|
||||||
|
["{", "}"],
|
||||||
|
['"', '"'],
|
||||||
|
["'", "'"],
|
||||||
|
["\u201C", "\u201D"], // \u201C
|
||||||
|
["\u2018", "\u2019"], // \u2018
|
||||||
|
];
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||||
|
formatted += "..." + close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||||
|
formatted = open + "..." + formatted;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[a-z]/.test(formatted)) {
|
||||||
|
formatted = "..." + formatted;
|
||||||
|
}
|
||||||
|
formatted = formatted.replace(/[,:;-—]$/, "...");
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="imposter-game">
|
||||||
|
{#if loading}
|
||||||
|
<p class="loading">Loading verses...</p>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error">
|
||||||
|
<p>Error: {error}</p>
|
||||||
|
<button on:click={newGame}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if data}
|
||||||
|
<!-- <div class="instructions">
|
||||||
|
<p>Click the verse that doesn't belong (from a different book).</p>
|
||||||
|
</div> -->
|
||||||
|
<div class="verses">
|
||||||
|
{#each data.verses as verse, i}
|
||||||
|
<div class="verse-item">
|
||||||
|
<button
|
||||||
|
class="verse-button"
|
||||||
|
class:clicked={clicked[i]}
|
||||||
|
class:correct={clicked[i] && i === data.imposterIndex}
|
||||||
|
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||||
|
on:click={() => handleClick(i)}
|
||||||
|
disabled={gameOver}
|
||||||
|
>
|
||||||
|
{formatVerse(verse)}
|
||||||
|
</button>
|
||||||
|
{#if gameOver}
|
||||||
|
<div class="ref">{data.refs[i]}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if gameOver}
|
||||||
|
<div class="result">
|
||||||
|
<button on:click={newGame}>New Game</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.imposter-game {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verses {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
border: 3px solid #ddd;
|
||||||
|
background: #fafafa;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-height: 100px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button:hover:not(.clicked):not(:disabled) {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #f8f9ff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button.clicked {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correct {
|
||||||
|
background: #d4edda !important;
|
||||||
|
border-color: #28a745 !important;
|
||||||
|
color: #155724;
|
||||||
|
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrong {
|
||||||
|
background: #f8d7da !important;
|
||||||
|
border-color: #dc3545 !important;
|
||||||
|
color: #721c24;
|
||||||
|
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
color: #555;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button.correct ~ .ref {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button.wrong ~ .ref {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result button,
|
||||||
|
.error button {
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result button:hover,
|
||||||
|
.error button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -353,6 +353,54 @@ export function getRandomGreekVerses(count: number = 3): {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a random set of verses from a specific book
|
||||||
|
* Returns `count` consecutive verses by default
|
||||||
|
*/
|
||||||
|
export function getRandomVersesFromBook(
|
||||||
|
bookNumber: number,
|
||||||
|
count: number = 1
|
||||||
|
): {
|
||||||
|
bookId: string;
|
||||||
|
bookName: string;
|
||||||
|
chapter: number;
|
||||||
|
startVerse: number;
|
||||||
|
endVerse: number;
|
||||||
|
verses: string[];
|
||||||
|
} | null {
|
||||||
|
const book = getBookByNumber(bookNumber);
|
||||||
|
if (!book) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try up to 10 times to find a valid passage
|
||||||
|
for (let attempt = 0; attempt < 10; attempt++) {
|
||||||
|
const chapterNumber = getRandomChapterNumber(bookNumber);
|
||||||
|
const verseCount = getVerseCount(bookNumber, chapterNumber);
|
||||||
|
|
||||||
|
// Skip chapters that don't have enough verses
|
||||||
|
if (verseCount < count) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startVerse = getRandomStartVerse(bookNumber, chapterNumber, count);
|
||||||
|
const verses = extractVerses(bookNumber, chapterNumber, startVerse, count);
|
||||||
|
|
||||||
|
if (verses.length === count) {
|
||||||
|
return {
|
||||||
|
bookId: book.id,
|
||||||
|
bookName: book.name,
|
||||||
|
chapter: chapterNumber,
|
||||||
|
startVerse,
|
||||||
|
endVerse: startVerse + count - 1,
|
||||||
|
verses
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a reference string from verse data
|
* Format a reference string from verse data
|
||||||
*/
|
*/
|
||||||
|
|||||||
68
src/routes/api/imposter/+server.ts
Normal file
68
src/routes/api/imposter/+server.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getRandomVersesFromBook } from '$lib/server/xml-bible';
|
||||||
|
|
||||||
|
interface VerseOption {
|
||||||
|
text: string;
|
||||||
|
isImposter: boolean;
|
||||||
|
ref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
try {
|
||||||
|
// Select two different random books (1-66)
|
||||||
|
let book1Num = Math.floor(Math.random() * 66) + 1;
|
||||||
|
let book2Num = Math.floor(Math.random() * 66) + 1;
|
||||||
|
while (book2Num === book1Num) {
|
||||||
|
book2Num = Math.floor(Math.random() * 66) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomly decide which is majority
|
||||||
|
const majorityBookNum = Math.random() < 0.5 ? book1Num : book2Num;
|
||||||
|
const imposterBookNum = majorityBookNum === book1Num ? book2Num : book1Num;
|
||||||
|
|
||||||
|
// Get 3 random verses from majority book
|
||||||
|
const options: VerseOption[] = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const verseData = getRandomVersesFromBook(majorityBookNum, 1);
|
||||||
|
if (!verseData) {
|
||||||
|
throw new Error('Failed to get majority verse');
|
||||||
|
}
|
||||||
|
options.push({
|
||||||
|
text: verseData.verses[0],
|
||||||
|
isImposter: false,
|
||||||
|
ref: `${verseData.bookName} ${verseData.chapter}:${verseData.startVerse}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 1 random verse from imposter book
|
||||||
|
const imposterVerseData = getRandomVersesFromBook(imposterBookNum, 1);
|
||||||
|
if (!imposterVerseData) {
|
||||||
|
throw new Error('Failed to get imposter verse');
|
||||||
|
}
|
||||||
|
options.push({
|
||||||
|
text: imposterVerseData.verses[0],
|
||||||
|
isImposter: true,
|
||||||
|
ref: `${imposterVerseData.bookName} ${imposterVerseData.chapter}:${imposterVerseData.startVerse}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fisher-Yates shuffle
|
||||||
|
for (let i = options.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[options[i], options[j]] = [options[j], options[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const verses = options.map(o => o.text);
|
||||||
|
const refs = options.map(o => o.ref);
|
||||||
|
const imposterIndex = options.findIndex(o => o.isImposter);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
verses,
|
||||||
|
refs,
|
||||||
|
imposterIndex
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Imposter API error:', error);
|
||||||
|
return json({ error: 'Failed to generate imposter game' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,214 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let sentence = $state("");
|
import Imposter from "$lib/components/Imposter.svelte";
|
||||||
let results = $state<
|
import Container from "$lib/components/Container.svelte";
|
||||||
Array<{
|
|
||||||
book: string;
|
|
||||||
chapter: number;
|
|
||||||
verse: number;
|
|
||||||
text: string;
|
|
||||||
score: number;
|
|
||||||
}>
|
|
||||||
>([]);
|
|
||||||
let loading = $state(false);
|
|
||||||
|
|
||||||
async function searchVerses() {
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/similar-verses", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ sentence, topK: 10 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.error) {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
results = data.results || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Search error:", error);
|
|
||||||
results = [];
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<svelte:head>
|
||||||
<h1 class="title">Similar Verse Finder</h1>
|
<title>Bibdle (imposter mode)</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="search-section">
|
<Container>
|
||||||
<input
|
<Container class="p-2 mt-12">
|
||||||
bind:value={sentence}
|
<h1><i>Imposter Mode</i></h1>
|
||||||
placeholder="Enter a sentence to find similar Bible verses..."
|
<p>Click the verse that doesn't belong</p>
|
||||||
class="input"
|
</Container>
|
||||||
/>
|
|
||||||
<button onclick={searchVerses} disabled={loading} class="button">
|
|
||||||
{loading ? "Searching..." : "Find Similar Verses"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if results.length > 0}
|
<Imposter />
|
||||||
<div class="results">
|
</Container>
|
||||||
{#each results as result, i (i)}
|
|
||||||
<article class="result">
|
|
||||||
<header>
|
|
||||||
<strong>{result.book} {result.chapter}:{result.verse}</strong>
|
|
||||||
<span class="score">Score: {result.score.toFixed(3)}</span>
|
|
||||||
</header>
|
|
||||||
<p>{result.text}</p>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if sentence.trim() && !loading}
|
|
||||||
<p class="no-results">No similar verses found. Try another sentence!</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem 0.75rem;
|
|
||||||
font-family:
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1.75rem;
|
|
||||||
font-size: clamp(2rem, 5vw, 3rem);
|
|
||||||
color: #2c3e50;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-section {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 300px;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: 2px solid #e1e5e9;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
background: #fafbfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover:not(:disabled) {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:disabled {
|
|
||||||
background: #a0aec0;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result {
|
|
||||||
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result strong {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
color: #1a202c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #718096;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result p {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #4a5568;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-results {
|
|
||||||
text-align: center;
|
|
||||||
padding: 1.75rem 0.75rem;
|
|
||||||
color: #a0aec0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.search-section {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
214
src/routes/similarity/+page.svelte
Normal file
214
src/routes/similarity/+page.svelte
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let sentence = $state("");
|
||||||
|
let results = $state<
|
||||||
|
Array<{
|
||||||
|
book: string;
|
||||||
|
chapter: number;
|
||||||
|
verse: number;
|
||||||
|
text: string;
|
||||||
|
score: number;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function searchVerses() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/similar-verses", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sentence, topK: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
results = data.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search error:", error);
|
||||||
|
results = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<h1 class="title">Similar Verse Finder</h1>
|
||||||
|
|
||||||
|
<div class="search-section">
|
||||||
|
<input
|
||||||
|
bind:value={sentence}
|
||||||
|
placeholder="Enter a sentence to find similar Bible verses..."
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<button onclick={searchVerses} disabled={loading} class="button">
|
||||||
|
{loading ? "Searching..." : "Find Similar Verses"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if results.length > 0}
|
||||||
|
<div class="results">
|
||||||
|
{#each results as result, i (i)}
|
||||||
|
<article class="result">
|
||||||
|
<header>
|
||||||
|
<strong>{result.book} {result.chapter}:{result.verse}</strong>
|
||||||
|
<span class="score">Score: {result.score.toFixed(3)}</span>
|
||||||
|
</header>
|
||||||
|
<p>{result.text}</p>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if sentence.trim() && !loading}
|
||||||
|
<p class="no-results">No similar verses found. Try another sentence!</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
color: #2c3e50;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid #e1e5e9;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
background: #a0aec0;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result strong {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #718096;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.75rem 0.75rem;
|
||||||
|
color: #a0aec0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-section {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
todo.md
4
todo.md
@@ -1,7 +1,8 @@
|
|||||||
# in progress
|
# in progress
|
||||||
|
|
||||||
- Show new/old testament after 3 guesses and section after 7 guesses
|
- Show new/old testament after 3 guesses and section after 7 guesses
|
||||||
|
- Add sections for "first letter", "Canonical/deutero", etc...
|
||||||
|
- Make the UI more "wordle-like" ()
|
||||||
- How do you balance rewarding knowledge vs incentivising learning?
|
- How do you balance rewarding knowledge vs incentivising learning?
|
||||||
|
|
||||||
# todo
|
# todo
|
||||||
@@ -55,6 +56,7 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
|
|
||||||
## 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...
|
||||||
- Verses ending in semicolons, commas, etc. will be replaced with "..."
|
- Verses ending in semicolons, commas, etc. will be replaced with "..."
|
||||||
|
|
||||||
## january 4th
|
## january 4th
|
||||||
|
|||||||
Reference in New Issue
Block a user