mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -04:00
Compare commits
26 Commits
embeddings
...
rss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3947e8adb0 | ||
|
|
244113671e | ||
|
|
5b9b2f76f4 | ||
|
|
f7ec0742e1 | ||
|
|
d797b980ea | ||
|
|
ff228fb547 | ||
|
|
d21ca9d687 | ||
|
|
2df97f66bf | ||
|
|
b1420a3e4f | ||
|
|
fe9cc09df6 | ||
|
|
55a9fd59ea | ||
|
|
0ee3d8a4d0 | ||
|
|
6365cfb363 | ||
|
|
860839fd75 | ||
|
|
e4b946ec8c | ||
|
|
b80c18c2aa | ||
|
|
8c488d27df | ||
|
|
77d6254a2c | ||
|
|
7fbed528f8 | ||
|
|
cec85be7c9 | ||
|
|
c50336ab5f | ||
|
|
03645f0452 | ||
|
|
cb11d793f6 | ||
|
|
ac1db94b0d | ||
|
|
1b1bc7bd3c | ||
|
|
0f6870344f |
11
.claude/settings.json
Normal file
11
.claude/settings.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"deny": [
|
||||||
|
"Read(./.env)",
|
||||||
|
"Read(./secrets/**)",
|
||||||
|
"Read(./config/credentials.json)",
|
||||||
|
"Read(./build)",
|
||||||
|
"Read(./embeddings**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
DATABASE_URL=example.db
|
||||||
|
PUBLIC_SITE_URL=https://bibdle.com
|
||||||
|
|
||||||
|
# nodemailer
|
||||||
|
SMTP_USERNAME=email@example.com
|
||||||
|
SMTP_TOKEN=TOKEN
|
||||||
|
SMTP_SERVER=smtp.example.com
|
||||||
|
SMTP_PORT=port
|
||||||
|
# note from mail provider: Enable TLS or SSL on the external service if it is supported.
|
||||||
|
|
||||||
|
# sign in with Discord
|
||||||
|
|
||||||
|
# sign in with google
|
||||||
|
|
||||||
|
# sign in with apple
|
||||||
|
AUTH_SECRET=your-random-secret-here
|
||||||
|
APPLE_ID=com.yourcompany.yourapp.client
|
||||||
|
APPLE_TEAM_ID=your-team-id
|
||||||
|
APPLE_KEY_ID=your-key-id
|
||||||
|
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
|
||||||
|
your-private-key-here
|
||||||
|
-----END PRIVATE KEY-----"
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -27,6 +27,6 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
llms-*
|
llms-*
|
||||||
|
|
||||||
|
embeddings*
|
||||||
|
*bible.xml
|
||||||
engwebu_usfx.xml
|
engwebu_usfx.xml
|
||||||
embeddings-cache-L12.json
|
|
||||||
embeddings-cache-L6.json
|
|
||||||
|
|||||||
@@ -24143,7 +24143,7 @@
|
|||||||
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
||||||
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
||||||
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
||||||
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
|
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.”</verse>
|
||||||
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
||||||
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
||||||
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
||||||
@@ -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>
|
||||||
|
|||||||
31
analyze_top_users.sh
Executable file
31
analyze_top_users.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# analyze_top_users.sh
|
||||||
|
# Analyzes the daily_completions table to find the top 10 anonymous IDs by completion count
|
||||||
|
|
||||||
|
# Set database path from argument or default to dev.db
|
||||||
|
DB_PATH="${1:-dev.db}"
|
||||||
|
|
||||||
|
# Check if database file exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "Error: Database file not found: $DB_PATH"
|
||||||
|
echo "Usage: $0 [database_path]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the analysis query
|
||||||
|
sqlite3 "$DB_PATH" <<EOF
|
||||||
|
.mode column
|
||||||
|
.headers on
|
||||||
|
.width 36 16 16 17
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
anonymous_id,
|
||||||
|
COUNT(*) as completion_count,
|
||||||
|
MIN(date) as first_completion,
|
||||||
|
MAX(date) as latest_completion
|
||||||
|
FROM daily_completions
|
||||||
|
GROUP BY anonymous_id
|
||||||
|
ORDER BY completion_count DESC
|
||||||
|
LIMIT 10;
|
||||||
|
EOF
|
||||||
46
bun.lock
46
bun.lock
@@ -6,27 +6,27 @@
|
|||||||
"name": "bibdle",
|
"name": "bibdle",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.6.2",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
"xml2js": "^0.6.2",
|
"xml2js": "^0.6.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@oslojs/encoding": "^1.1.0",
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.49.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22.19.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.0",
|
"drizzle-orm": "^0.45.1",
|
||||||
"svelte": "^5.45.6",
|
"svelte": "^5.48.3",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.3.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -187,11 +187,11 @@
|
|||||||
|
|
||||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
|
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
|
||||||
|
|
||||||
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.4.0", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ=="],
|
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.2", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-L15Djwpr7HrSAPj/Z8PYfc0pa9A1tllrr18phKI0WJHJeoWw45yinPf0IGgVTmakqx1B3JQ+C/OFl9ZwmxHU1Q=="],
|
||||||
|
|
||||||
"@sveltejs/kit": ["@sveltejs/kit@2.49.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ=="],
|
"@sveltejs/kit": ["@sveltejs/kit@2.50.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw=="],
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="],
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
|
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
|
|
||||||
"@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="],
|
"@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
|
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
|
||||||
|
|
||||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||||
|
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="],
|
"better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
|
||||||
|
|
||||||
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||||
|
|
||||||
@@ -303,7 +303,7 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
|
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
||||||
|
|
||||||
@@ -421,6 +421,8 @@
|
|||||||
|
|
||||||
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
|
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
"onnx-proto": ["onnx-proto@4.0.4", "", { "dependencies": { "protobufjs": "^6.8.8" } }, "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA=="],
|
"onnx-proto": ["onnx-proto@4.0.4", "", { "dependencies": { "protobufjs": "^6.8.8" } }, "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA=="],
|
||||||
@@ -497,9 +499,9 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"svelte": ["svelte@5.46.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw=="],
|
"svelte": ["svelte@5.48.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NB3o70OxfmnE5UPyLr8uH3IV02Q43qJVAuWigYmsSOYsS0s/rHxP0TF81blG0onF/xkhNvZw4G8NfzIX+By5ZQ=="],
|
||||||
|
|
||||||
"svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="],
|
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
@@ -523,7 +525,7 @@
|
|||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||||
|
|
||||||
@@ -551,8 +553,12 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@types/better-sqlite3/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
|
||||||
|
|
||||||
"prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
"prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||||
|
|
||||||
|
"protobufjs/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
|
||||||
|
|
||||||
"vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="],
|
"vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
|
|||||||
20
clear-today-verse.sh
Executable file
20
clear-today-verse.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
# Clear today's verse from daily_verses table
|
||||||
|
DB_PATH="dev.db"
|
||||||
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
|
echo "Deleting verse for date: $TODAY"
|
||||||
|
|
||||||
|
sqlite3 "$DB_PATH" "DELETE FROM daily_verses WHERE date = '$TODAY';"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ Successfully deleted verse for $TODAY"
|
||||||
|
|
||||||
|
# Show remaining verses in table
|
||||||
|
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_verses;")
|
||||||
|
echo "Remaining verses in database: $COUNT"
|
||||||
|
else
|
||||||
|
echo "✗ Failed to delete verse"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
34
daily_completions_report.sh
Executable file
34
daily_completions_report.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
DB_PATH="./local.db"
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "Error: Database not found at $DB_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Query for daily completions on 2026-02-01 with ranking
|
||||||
|
echo "Daily Completions for 2026-02-01"
|
||||||
|
echo "================================="
|
||||||
|
echo ""
|
||||||
|
printf "%-12s %-10s %-6s\n" "Anonymous ID" "Guesses" "Rank"
|
||||||
|
printf "%-12s %-10s %-6s\n" "------------" "-------" "----"
|
||||||
|
|
||||||
|
# Execute query with custom column mode
|
||||||
|
sqlite3 "$DB_PATH" <<SQL
|
||||||
|
.mode column
|
||||||
|
.headers off
|
||||||
|
.width 12 10 6
|
||||||
|
SELECT
|
||||||
|
SUBSTR(anonymous_id, 1, 10) as anon_id,
|
||||||
|
guess_count,
|
||||||
|
RANK() OVER (ORDER BY guess_count ASC) as rank
|
||||||
|
FROM daily_completions
|
||||||
|
WHERE date = '2026-02-01'
|
||||||
|
ORDER BY rank, guess_count;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Total entries:"
|
||||||
|
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '2026-02-01';"
|
||||||
18
deploy.sh
Executable file
18
deploy.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "Pulling latest changes..."
|
||||||
|
git pull
|
||||||
|
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
bun i
|
||||||
|
|
||||||
|
echo "Building..."
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
echo "Restarting service..."
|
||||||
|
sudo systemctl restart bibdle
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
24
package.json
24
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bibdle",
|
"name": "bibdle",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "2.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -18,24 +18,24 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@oslojs/encoding": "^1.1.0",
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.49.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22.19.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.0",
|
"drizzle-orm": "^0.45.1",
|
||||||
"svelte": "^5.45.6",
|
"svelte": "^5.48.5",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.6"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.6.2",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import Database from 'bun:sqlite';
|
|||||||
|
|
||||||
// Database path - adjust if your database is located elsewhere
|
// Database path - adjust if your database is located elsewhere
|
||||||
const dbPath = process.env.DATABASE_URL || './local.db';
|
const dbPath = process.env.DATABASE_URL || './local.db';
|
||||||
|
|
||||||
console.log(`Connecting to database: ${dbPath}`);
|
console.log(`Connecting to database: ${dbPath}`);
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
// Query all rows from daily_completions
|
// Query all rows from daily_completions
|
||||||
@@ -13,7 +11,6 @@ const query = db.query(`
|
|||||||
FROM daily_completions
|
FROM daily_completions
|
||||||
ORDER BY date
|
ORDER BY date
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const rows = query.all() as { date: string; guess_count: number }[];
|
const rows = query.all() as { date: string; guess_count: number }[];
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -50,4 +47,60 @@ const overallAvg = (totalGuesses / totalCompletions).toFixed(2);
|
|||||||
console.log('--------------|-------------|-------------------');
|
console.log('--------------|-------------|-------------------');
|
||||||
console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`);
|
console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`);
|
||||||
|
|
||||||
|
// Calculate correlation between avg_guesses and completions
|
||||||
|
function calculateCorrelation(data: { avgGuesses: number; completions: number }[]): number {
|
||||||
|
const n = data.length;
|
||||||
|
if (n < 2) return 0;
|
||||||
|
|
||||||
|
const avgX = data.reduce((sum, d) => sum + d.avgGuesses, 0) / n;
|
||||||
|
const avgY = data.reduce((sum, d) => sum + d.completions, 0) / n;
|
||||||
|
|
||||||
|
let numerator = 0;
|
||||||
|
let sumXSquared = 0;
|
||||||
|
let sumYSquared = 0;
|
||||||
|
|
||||||
|
for (const d of data) {
|
||||||
|
const xDiff = d.avgGuesses - avgX;
|
||||||
|
const yDiff = d.completions - avgY;
|
||||||
|
numerator += xDiff * yDiff;
|
||||||
|
sumXSquared += xDiff * xDiff;
|
||||||
|
sumYSquared += yDiff * yDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const denominator = Math.sqrt(sumXSquared * sumYSquared);
|
||||||
|
return denominator === 0 ? 0 : numerator / denominator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare data for correlation analysis
|
||||||
|
const allData = Array.from(dateStats.entries()).map(([date, stats]) => ({
|
||||||
|
date,
|
||||||
|
avgGuesses: stats.total / stats.count,
|
||||||
|
completions: stats.count
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Split into pre and post marketing periods
|
||||||
|
const marketingStartDate = '2026-01-08';
|
||||||
|
const preMarketing = allData.filter(d => d.date < marketingStartDate);
|
||||||
|
const postMarketing = allData.filter(d => d.date >= marketingStartDate);
|
||||||
|
|
||||||
|
console.log('\n=== Correlation Analysis ===\n');
|
||||||
|
|
||||||
|
const allCorrelation = calculateCorrelation(allData);
|
||||||
|
console.log(`Overall correlation (avg_guesses vs completions): ${allCorrelation.toFixed(3)}`);
|
||||||
|
|
||||||
|
if (preMarketing.length >= 2) {
|
||||||
|
const preCorrelation = calculateCorrelation(preMarketing);
|
||||||
|
console.log(`Pre-marketing correlation (before ${marketingStartDate}): ${preCorrelation.toFixed(3)} (n=${preMarketing.length} days)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postMarketing.length >= 2) {
|
||||||
|
const postCorrelation = calculateCorrelation(postMarketing);
|
||||||
|
console.log(`Post-marketing correlation (${marketingStartDate} onward): ${postCorrelation.toFixed(3)} (n=${postMarketing.length} days)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nInterpretation:');
|
||||||
|
console.log(' r close to -1: Strong negative correlation (easier verses → more completions)');
|
||||||
|
console.log(' r close to 0: No correlation');
|
||||||
|
console.log(' r close to +1: Strong positive correlation (harder verses → more completions)');
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
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 |
39
src/lib/components/Button.svelte
Normal file
39
src/lib/components/Button.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
variant?: "primary" | "google" | "apple" | "secondary" | "danger";
|
||||||
|
onclick?: () => void;
|
||||||
|
class?: string;
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
variant = "primary",
|
||||||
|
onclick,
|
||||||
|
class: className = "",
|
||||||
|
type = "button",
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary:
|
||||||
|
"bg-blue-500 hover:bg-blue-600 text-white border-gray-500 shadow-md hover:shadow-lg",
|
||||||
|
google: "bg-white hover:bg-gray-50 text-gray-700 border-gray-500 shadow-md hover:shadow-lg",
|
||||||
|
apple: "bg-black hover:bg-gray-900 text-white border-gray-500 shadow-md hover:shadow-lg",
|
||||||
|
secondary:
|
||||||
|
"bg-white/50 hover:bg-white/70 text-gray-700 border-gray-500 shadow-sm hover:shadow-md backdrop-blur-sm",
|
||||||
|
danger: "bg-red-500 hover:bg-red-600 text-white border-gray-500 shadow-md hover:shadow-lg",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
{type}
|
||||||
|
{onclick}
|
||||||
|
class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[
|
||||||
|
variant
|
||||||
|
]} {className}"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</button>
|
||||||
222
src/lib/components/ChapterGuess.svelte
Normal file
222
src/lib/components/ChapterGuess.svelte
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reference: string;
|
||||||
|
bookId: string;
|
||||||
|
onCompleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { reference, bookId, onCompleted }: Props = $props();
|
||||||
|
|
||||||
|
// Parse the chapter from the reference (e.g., "John 3:16" -> 3)
|
||||||
|
function parseChapterFromReference(ref: string): number {
|
||||||
|
const match = ref.match(/\s(\d+):/);
|
||||||
|
return match ? parseInt(match[1], 10) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the number of chapters for a book
|
||||||
|
function getChapterCount(bookId: string): number {
|
||||||
|
const chapterCounts: Record<string, number> = {
|
||||||
|
GEN: 50,
|
||||||
|
EXO: 40,
|
||||||
|
LEV: 27,
|
||||||
|
NUM: 36,
|
||||||
|
DEU: 34,
|
||||||
|
JOS: 24,
|
||||||
|
JDG: 21,
|
||||||
|
RUT: 4,
|
||||||
|
"1SA": 31,
|
||||||
|
"2SA": 24,
|
||||||
|
"1KI": 22,
|
||||||
|
"2KI": 25,
|
||||||
|
"1CH": 29,
|
||||||
|
"2CH": 36,
|
||||||
|
EZR: 10,
|
||||||
|
NEH: 13,
|
||||||
|
EST: 10,
|
||||||
|
JOB: 42,
|
||||||
|
PSA: 150,
|
||||||
|
PRO: 31,
|
||||||
|
ECC: 12,
|
||||||
|
SNG: 8,
|
||||||
|
ISA: 66,
|
||||||
|
JER: 52,
|
||||||
|
LAM: 5,
|
||||||
|
EZK: 48,
|
||||||
|
DAN: 12,
|
||||||
|
HOS: 14,
|
||||||
|
JOL: 3,
|
||||||
|
AMO: 9,
|
||||||
|
OBA: 1,
|
||||||
|
JON: 4,
|
||||||
|
MIC: 7,
|
||||||
|
NAM: 3,
|
||||||
|
HAB: 3,
|
||||||
|
ZEP: 3,
|
||||||
|
HAG: 2,
|
||||||
|
ZEC: 14,
|
||||||
|
MAL: 4,
|
||||||
|
MAT: 28,
|
||||||
|
MRK: 16,
|
||||||
|
LUK: 24,
|
||||||
|
JHN: 21,
|
||||||
|
ACT: 28,
|
||||||
|
ROM: 16,
|
||||||
|
"1CO": 16,
|
||||||
|
"2CO": 13,
|
||||||
|
GAL: 6,
|
||||||
|
EPH: 6,
|
||||||
|
PHP: 4,
|
||||||
|
COL: 4,
|
||||||
|
"1TH": 5,
|
||||||
|
"2TH": 3,
|
||||||
|
"1TI": 6,
|
||||||
|
"2TI": 4,
|
||||||
|
TIT: 3,
|
||||||
|
PHM: 1,
|
||||||
|
HEB: 13,
|
||||||
|
JAS: 5,
|
||||||
|
"1PE": 5,
|
||||||
|
"2PE": 3,
|
||||||
|
"1JN": 5,
|
||||||
|
"2JN": 1,
|
||||||
|
"3JN": 1,
|
||||||
|
JUD: 1,
|
||||||
|
REV: 22,
|
||||||
|
};
|
||||||
|
return chapterCounts[bookId] || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 6 random chapter options including the correct one
|
||||||
|
function generateChapterOptions(
|
||||||
|
correctChapter: number,
|
||||||
|
totalChapters: number,
|
||||||
|
): number[] {
|
||||||
|
const options = new Set<number>();
|
||||||
|
options.add(correctChapter);
|
||||||
|
|
||||||
|
if (totalChapters >= 6) {
|
||||||
|
while (options.size < 6) {
|
||||||
|
const randomChapter =
|
||||||
|
Math.floor(Math.random() * totalChapters) + 1;
|
||||||
|
options.add(randomChapter);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (options.size < 6) {
|
||||||
|
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||||
|
options.add(randomChapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(options).sort(() => Math.random() - 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
let correctChapter = $derived(parseChapterFromReference(reference));
|
||||||
|
let totalChapters = $derived(getChapterCount(bookId));
|
||||||
|
let chapterOptions = $state<number[]>([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (chapterOptions.length === 0) {
|
||||||
|
chapterOptions = generateChapterOptions(
|
||||||
|
correctChapter,
|
||||||
|
totalChapters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectedChapter = $state<number | null>(null);
|
||||||
|
let hasAnswered = $state(false);
|
||||||
|
|
||||||
|
// Load saved state from localStorage
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const key = `bibdle-chapter-guess-${reference}`;
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
selectedChapter = data.selectedChapter;
|
||||||
|
hasAnswered = data.hasAnswered;
|
||||||
|
chapterOptions = data.chapterOptions ?? [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save state to localStorage whenever options are generated or answer given
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || chapterOptions.length === 0) return;
|
||||||
|
const key = `bibdle-chapter-guess-${reference}`;
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({ selectedChapter, hasAnswered, chapterOptions }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChapterSelect(chapter: number) {
|
||||||
|
if (hasAnswered) return;
|
||||||
|
selectedChapter = chapter;
|
||||||
|
hasAnswered = true;
|
||||||
|
if (onCompleted) {
|
||||||
|
onCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCorrect = $derived(
|
||||||
|
selectedChapter !== null && selectedChapter === correctChapter,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Container
|
||||||
|
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
||||||
|
<p class="text-sm sm:text-base opacity-80 mb-6">
|
||||||
|
Guess the chapter for an even higher grade
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
||||||
|
>
|
||||||
|
{#each chapterOptions as chapter}
|
||||||
|
<button
|
||||||
|
onclick={() => handleChapterSelect(chapter)}
|
||||||
|
disabled={hasAnswered}
|
||||||
|
class={`
|
||||||
|
w-20 h-20 sm:w-24 sm:h-24 text-2xl sm:text-3xl font-bold rounded-xl
|
||||||
|
transition-all duration-300 border-2
|
||||||
|
${
|
||||||
|
hasAnswered
|
||||||
|
? chapter === correctChapter
|
||||||
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
|
: selectedChapter === chapter
|
||||||
|
? isCorrect
|
||||||
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
|
: "bg-red-400 text-white border-red-500"
|
||||||
|
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
|
||||||
|
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{chapter}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if hasAnswered}
|
||||||
|
<p
|
||||||
|
class="text-xl sm:text-2xl font-bold mb-2"
|
||||||
|
class:text-green-600={isCorrect}
|
||||||
|
class:text-red-600={!isCorrect}
|
||||||
|
>
|
||||||
|
{isCorrect ? "✓ Correct!" : "✗ Incorrect"}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm opacity-80">
|
||||||
|
The verse is from chapter {correctChapter}
|
||||||
|
</p>
|
||||||
|
{#if isCorrect}
|
||||||
|
<p class="text-lg font-bold text-amber-600 mt-2">Grade: S++</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
16
src/lib/components/Container.svelte
Normal file
16
src/lib/components/Container.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className = "" }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm rounded-2xl border border-white/50 shadow-sm {className}"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -74,13 +74,11 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center py-12">
|
<div class="w-full">
|
||||||
<div
|
<div
|
||||||
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
|
||||||
>
|
>
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
|
||||||
Next Verse In
|
Next Verse In
|
||||||
</p>
|
</p>
|
||||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
||||||
|
|||||||
62
src/lib/components/Credits.svelte
Normal file
62
src/lib/components/Credits.svelte
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-2 bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||||
|
A project by George Powell & Silent Summit Co.
|
||||||
|
</p>
|
||||||
|
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||||
|
For questions, comments, job opportunities, or cash donations,
|
||||||
|
please email <a
|
||||||
|
class="text-blue-400"
|
||||||
|
href="mailto:george+bibdle@silentsummit.co"
|
||||||
|
>george@silentsummit.co</a
|
||||||
|
>
|
||||||
|
</p> -->
|
||||||
|
<!-- <p class="text-lg font-triodion font-black text-gray-800 tabular-nums">
|
||||||
|
|
||||||
|
</p> -->
|
||||||
|
|
||||||
|
<!-- Bluesky Social Media Button -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex items-center justify-center gap-6">
|
||||||
|
<a
|
||||||
|
href="https://bsky.app/profile/snail.city"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="Follow on Bluesky"
|
||||||
|
>
|
||||||
|
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="w-0.5 h-8 bg-gray-400"></div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="mailto:george+bibdle@silentsummit.co"
|
||||||
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="Send email"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8 text-gray-700"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
89
src/lib/components/DevButtons.svelte
Normal file
89
src/lib/components/DevButtons.svelte
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import Button from "$lib/components/Button.svelte";
|
||||||
|
|
||||||
|
function clearLocalStorage() {
|
||||||
|
if (!browser) return;
|
||||||
|
// Clear all bibdle-related localStorage items
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith("bibdle-")) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
// Reload the page to reset state
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pt-24 pb-4">
|
||||||
|
<div class="border-t-2 border-gray-400"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={() => alert("About page coming soon!")}
|
||||||
|
class="w-full md:w-auto py-4 md:py-2"
|
||||||
|
>
|
||||||
|
About Bibdle / FAQs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="google"
|
||||||
|
onclick={() => alert("Google sign-in coming soon!")}
|
||||||
|
class="w-full md:w-auto py-4 md:py-2"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="apple"
|
||||||
|
onclick={() => alert("Apple sign-in coming soon!")}
|
||||||
|
class="w-full md:w-auto py-4 md:py-2"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Apple
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onclick={() => alert("Patreon coming soon!")}
|
||||||
|
class="w-full md:w-auto py-4 md:py-2"
|
||||||
|
>
|
||||||
|
Become a Patron
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onclick={clearLocalStorage}
|
||||||
|
class="w-full py-4 md:py-2"
|
||||||
|
>
|
||||||
|
Clear LocalStorage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- <div
|
|
||||||
class="my-12 p-4 bg-linear-to-r from-blue-50 to-indigo-50 rounded-2xl shadow-md text-center text-sm md:text-base text-gray-600"
|
|
||||||
in:fade={{ delay: 1500, duration: 1000 }}
|
|
||||||
>
|
|
||||||
Thank you so much for playing! Feel free to email me directly with feedback:
|
|
||||||
<a
|
|
||||||
href="mailto:george@snail.city"
|
|
||||||
class="font-semibold text-blue-600 hover:text-blue-800 underline"
|
|
||||||
>george@snail.city</a
|
|
||||||
>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<div
|
|
||||||
class="inline-flex w-full flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
|
||||||
>
|
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
|
||||||
A project by George Powell & Silent Summit Co.
|
|
||||||
</p>
|
|
||||||
<!-- <p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
|
||||||
|
|
||||||
</p> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
interface Guess {
|
interface Guess {
|
||||||
book: {
|
book: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,83 +12,192 @@
|
|||||||
testamentMatch: boolean;
|
testamentMatch: boolean;
|
||||||
sectionMatch: boolean;
|
sectionMatch: boolean;
|
||||||
adjacent: boolean;
|
adjacent: boolean;
|
||||||
|
firstLetterMatch: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { guesses, correctBookId }: { guesses: Guess[]; correctBookId: string } =
|
let {
|
||||||
$props();
|
guesses,
|
||||||
|
correctBookId,
|
||||||
|
}: { guesses: Guess[]; correctBookId: string } = $props();
|
||||||
|
|
||||||
let hasGuesses = $derived(guesses.length > 0);
|
let hasGuesses = $derived(guesses.length > 0);
|
||||||
|
|
||||||
|
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
|
||||||
|
if (isCorrect) return "bg-green-500 border-green-600";
|
||||||
|
if (isAdjacent) return "bg-yellow-500 border-yellow-600";
|
||||||
|
return "bg-red-500 border-red-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstLetter(bookName: string): string {
|
||||||
|
const match = bookName.match(/[a-zA-Z]/);
|
||||||
|
return match ? match[0] : bookName[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoxContent(
|
||||||
|
guess: Guess,
|
||||||
|
column: "book" | "firstLetter" | "testament" | "section",
|
||||||
|
): string {
|
||||||
|
switch (column) {
|
||||||
|
case "book":
|
||||||
|
return guess.book.name;
|
||||||
|
case "firstLetter":
|
||||||
|
// Check if this is the special Epistles + "1" case
|
||||||
|
const correctBook = bibleBooks.find(
|
||||||
|
(b) => b.id === correctBookId,
|
||||||
|
);
|
||||||
|
const correctIsEpistlesWithNumber =
|
||||||
|
(correctBook?.section === "Pauline Epistles" ||
|
||||||
|
correctBook?.section === "General Epistles") &&
|
||||||
|
correctBook.name[0] === "1";
|
||||||
|
const guessIsEpistlesWithNumber =
|
||||||
|
(guess.book.section === "Pauline Epistles" ||
|
||||||
|
guess.book.section === "General Epistles") &&
|
||||||
|
guess.book.name[0] === "1";
|
||||||
|
|
||||||
|
if (
|
||||||
|
correctIsEpistlesWithNumber &&
|
||||||
|
guessIsEpistlesWithNumber &&
|
||||||
|
guess.firstLetterMatch
|
||||||
|
) {
|
||||||
|
const words = [
|
||||||
|
"Exactly",
|
||||||
|
"Right",
|
||||||
|
"Yes",
|
||||||
|
"Naturally",
|
||||||
|
"Of course",
|
||||||
|
"Sure",
|
||||||
|
];
|
||||||
|
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
|
||||||
|
}
|
||||||
|
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
|
||||||
|
case "testament":
|
||||||
|
return (
|
||||||
|
guess.book.testament.charAt(0).toUpperCase() +
|
||||||
|
guess.book.testament.slice(1).toLowerCase()
|
||||||
|
);
|
||||||
|
case "section":
|
||||||
|
return guess.book.section;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if hasGuesses}
|
{#if !hasGuesses}
|
||||||
<div class="bg-white rounded-2xl shadow-xl overflow-x-auto fade-in">
|
<Container class="p-6 text-center">
|
||||||
<table class="w-full">
|
<h2 class="font-triodion text-xl italic mb-3 text-gray-800">
|
||||||
<thead class="fade-in">
|
Instructions
|
||||||
<tr class="bg-linear-to-r from-gray-50 to-gray-300">
|
</h2>
|
||||||
<th
|
<p class="text-gray-700 leading-relaxed italic">
|
||||||
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200"
|
Guess what book of the bible you think the verse is from. You will
|
||||||
>Book</th
|
get clues to tell you if your guess is close or not. Green means the
|
||||||
|
category is correct; red means wrong.
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Column Headers -->
|
||||||
|
<div
|
||||||
|
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
||||||
>
|
>
|
||||||
<th
|
<div
|
||||||
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200"
|
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||||
>Testament</th
|
|
||||||
>
|
>
|
||||||
<th
|
Book
|
||||||
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200"
|
|
||||||
>Section</th
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each guesses as guess, index (guess.book.id)}
|
|
||||||
<tr
|
|
||||||
class="border-b border-gray-100 transition-colors {guess.book.id ===
|
|
||||||
correctBookId
|
|
||||||
? 'bg-green-200 animate-shine'
|
|
||||||
: 'hover:bg-gray-50'} {index === 0 ? 'fade-in' : ''}"
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="p-3 sm:p-4 md:p-6 text-sm sm:text-base font-bold md:text-lg"
|
|
||||||
>
|
|
||||||
{guess.book.id === correctBookId ? "✅" : "❌"}
|
|
||||||
{guess.book.name}
|
|
||||||
</td>
|
|
||||||
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg">
|
|
||||||
{guess.testamentMatch ? "✅" : "❌"}
|
|
||||||
{guess.book.testament.charAt(0).toUpperCase() +
|
|
||||||
guess.book.testament.slice(1).toLowerCase()}
|
|
||||||
</td>
|
|
||||||
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg">
|
|
||||||
{guess.sectionMatch ? "✅" : "❌"}
|
|
||||||
{guess.adjacent ? "‼️ " : ""}{guess.book.section}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||||
|
>
|
||||||
|
Testament
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||||
|
>
|
||||||
|
Section
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||||
|
>
|
||||||
|
First Letter
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<!-- Book Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
|
||||||
|
guess.book.id === correctBookId,
|
||||||
|
)}"
|
||||||
|
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||||
|
>{getBoxContent(guess, "book")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testament Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.testamentMatch,
|
||||||
|
)}"
|
||||||
|
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "testament")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Column -->
|
||||||
|
<div
|
||||||
|
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.sectionMatch,
|
||||||
|
guess.adjacent,
|
||||||
|
)}"
|
||||||
|
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "section")}
|
||||||
|
{#if guess.adjacent}
|
||||||
|
‼️
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Letter Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.firstLetterMatch,
|
||||||
|
)}"
|
||||||
|
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "firstLetter")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<!-- </div> -->
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes shine {
|
@keyframes flipIn {
|
||||||
0% {
|
0% {
|
||||||
background-position: -200% 0;
|
opacity: 0;
|
||||||
|
transform: rotateX(-90deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotateX(0deg);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background-position: 200% 0;
|
opacity: 1;
|
||||||
|
transform: rotateX(0deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-shine {
|
.animate-flip-in {
|
||||||
background: linear-gradient(110deg, #dcffe7 45%, #f1fff5 50%, #dcffe7 55%);
|
opacity: 0;
|
||||||
background-size: 200% 100%;
|
transform: rotateX(-90deg);
|
||||||
animation: shine 5s infinite;
|
animation: flipIn 0.6s ease-out forwards;
|
||||||
}
|
|
||||||
|
|
||||||
.animate-shine.fade-in {
|
|
||||||
animation:
|
|
||||||
fadeIn 0.5s ease-out,
|
|
||||||
shine 5s infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
|
|||||||
246
src/lib/components/Imposter.svelte
Normal file
246
src/lib/components/Imposter.svelte
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
// Replace trailing punctuation with ellipsis
|
||||||
|
// Preserve closing quotes/brackets that may have been added
|
||||||
|
formatted = formatted.replace(
|
||||||
|
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
||||||
|
"...$1",
|
||||||
|
);
|
||||||
|
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>
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
let filteredBooks = $derived(
|
let filteredBooks = $derived(
|
||||||
bibleBooks.filter((book) =>
|
bibleBooks.filter((book) =>
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
@@ -16,23 +16,60 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-12">
|
<div class="relative">
|
||||||
|
<div class="relative">
|
||||||
|
<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"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<input
|
<input
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||||
class="w-full p-4 sm:p-6 border-2 border-gray-200 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all shadow-lg"
|
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
|
{#if searchQuery}
|
||||||
|
<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"
|
||||||
|
onclick={() => (searchQuery = "")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</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-200 rounded-2xl shadow-lg"
|
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
|
||||||
>
|
>
|
||||||
{#each filteredBooks as book (book.id)}
|
{#each filteredBooks as book (book.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(
|
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
|
||||||
book.id,
|
|
||||||
)
|
|
||||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
||||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
||||||
onclick={() => submitGuess(book.id)}
|
onclick={() => submitGuess(book.id)}
|
||||||
|
|||||||
@@ -1,25 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||||
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
let { data, isWon }: { data: PageData; isWon: boolean } = $props();
|
let {
|
||||||
|
data,
|
||||||
|
isWon,
|
||||||
|
blurChapter = false,
|
||||||
|
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
let displayReference = $derived(
|
let displayReference = $derived(
|
||||||
dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
blurChapter
|
||||||
|
? dailyVerse.reference
|
||||||
|
.replace(/^Psalms /, "Psalm ")
|
||||||
|
.replace(/\s(\d+):/, " ?:")
|
||||||
|
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
||||||
);
|
);
|
||||||
let displayVerseText = $derived(
|
let displayVerseText = $derived(
|
||||||
dailyVerse.verseText.replace(/^([a-z])/, (c) => c.toUpperCase())
|
dailyVerse.verseText
|
||||||
|
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||||
|
.replace(/[,:;-—]$/, "...")
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-gray-50 rounded-2xl shadow-xl p-8 sm:p-12 mb-4 sm:mb-12 w-full">
|
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
||||||
<blockquote
|
<blockquote
|
||||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
||||||
>
|
>
|
||||||
{displayVerseText}
|
{displayVerseText}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
{#if isWon}
|
{#if isWon}
|
||||||
<p class="text-center text-lg! big-text text-green-600! font-bold mt-8">
|
<p
|
||||||
|
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
||||||
|
>
|
||||||
{displayReference}
|
{displayReference}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</Container>
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { getBookById, toOrdinal, getNextGradeMessage } from "$lib/utils/game";
|
import {
|
||||||
|
getBookById,
|
||||||
|
toOrdinal,
|
||||||
|
getNextGradeMessage,
|
||||||
|
} from "$lib/utils/game";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import Container from "./Container.svelte";
|
||||||
|
import CountdownTimer from "./CountdownTimer.svelte";
|
||||||
|
import ChapterGuess from "./ChapterGuess.svelte";
|
||||||
|
|
||||||
interface StatsData {
|
interface StatsData {
|
||||||
solveRank: number;
|
solveRank: number;
|
||||||
guessRank: number;
|
guessRank: number;
|
||||||
totalSolves: number;
|
totalSolves: number;
|
||||||
averageGuesses: number;
|
averageGuesses: number;
|
||||||
|
tiedCount: number;
|
||||||
|
percentile: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeightedMessage {
|
interface WeightedMessage {
|
||||||
@@ -24,11 +33,13 @@
|
|||||||
copied = $bindable(false),
|
copied = $bindable(false),
|
||||||
statsSubmitted,
|
statsSubmitted,
|
||||||
guessCount,
|
guessCount,
|
||||||
|
reference,
|
||||||
|
onChapterGuessCompleted,
|
||||||
} = $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);
|
||||||
|
|
||||||
@@ -36,7 +47,7 @@
|
|||||||
const congratulationsMessages: WeightedMessage[] = [
|
const congratulationsMessages: WeightedMessage[] = [
|
||||||
{ text: "Congratulations!", weight: 10 },
|
{ text: "Congratulations!", weight: 10 },
|
||||||
{ text: "You got it!", weight: 1000 },
|
{ text: "You got it!", weight: 1000 },
|
||||||
{ text: "Yup,", weight: 100 },
|
{ text: "Yup.", weight: 100 },
|
||||||
{ text: "Very nice!", weight: 1 },
|
{ text: "Very nice!", weight: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -54,7 +65,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -73,27 +84,29 @@
|
|||||||
let congratulationsMessage = $derived(getRandomCongratulationsMessage());
|
let congratulationsMessage = $derived(getRandomCongratulationsMessage());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="flex flex-col gap-6">
|
||||||
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 fade-in"
|
<Container
|
||||||
|
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
|
||||||
>
|
>
|
||||||
<!-- <h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg">
|
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
|
||||||
{congratulationsMessage}
|
|
||||||
</h2> -->
|
|
||||||
<p class="text-xl sm:text-3xl md:text-4xl">
|
|
||||||
{congratulationsMessage} The verse is from
|
{congratulationsMessage} The verse is from
|
||||||
<span class="font-black text-xl sm:text-2xl md:text-3xl">{bookName}</span>.
|
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-lg sm:text-xl md:text-2xl mt-4">
|
||||||
class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block"
|
You guessed correctly after {guessCount}
|
||||||
|
{guessCount === 1 ? "guess" : "guesses"}.
|
||||||
|
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
|
||||||
|
>{grade}</span
|
||||||
>
|
>
|
||||||
Your grade: {grade}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-6">
|
||||||
{#if hasWebShare}
|
{#if hasWebShare}
|
||||||
|
<!-- mobile and arc in production -->
|
||||||
<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="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
|
||||||
>
|
>
|
||||||
📤 Share
|
📤 Share
|
||||||
</button>
|
</button>
|
||||||
@@ -106,75 +119,111 @@
|
|||||||
}, 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={`text-2xl font-bold p-4 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-white/30"
|
||||||
: "bg-white/20 hover:bg-white/30"
|
: "bg-white/70 hover:bg-white/80"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
|
{copySuccess ? "✅ Copied!" : "📋 Copy"}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- dev mode and desktop browsers -->
|
||||||
<button
|
<button
|
||||||
onclick={handleShare}
|
onclick={handleShare}
|
||||||
data-umami-event="Share"
|
data-umami-event="Copy to Clipboard"
|
||||||
class={`mt-4 text-2xl font-bold p-2 ${
|
class={`text-2xl font-bold p-4 ${
|
||||||
copied
|
copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
|
||||||
? "bg-green-400/50 hover:bg-green-500/60"
|
} rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
||||||
: "bg-white/20 hover:bg-white/30"
|
|
||||||
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
|
||||||
>
|
>
|
||||||
{copied ? "Copied to clipboard!" : "📤 Share"}
|
{copied ? "✅ Copied!" : "📋 Share"}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="pt-6 big-text text-gray-100!">
|
{#if guessCount !== 1}
|
||||||
|
<p class="pt-6 big-text text-gray-700!">
|
||||||
{getNextGradeMessage(guessCount)}
|
{getNextGradeMessage(guessCount)}
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<!-- S++ Bonus Challenge for first try -->
|
||||||
|
{#if guessCount === 1}
|
||||||
|
<ChapterGuess
|
||||||
|
{reference}
|
||||||
|
bookId={correctBookId}
|
||||||
|
onCompleted={onChapterGuessCompleted}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<CountdownTimer />
|
||||||
|
|
||||||
<!-- Statistics Display -->
|
<!-- Statistics Display -->
|
||||||
{#if statsData}
|
{#if statsData}
|
||||||
<div class="mt-6" in:fade={{ delay: 800 }}>
|
<Container
|
||||||
<div class="grid grid-cols-3 gap-4 gap-x-8 text-center">
|
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||||
|
in:fade={{ delay: 800 }}
|
||||||
|
>
|
||||||
<!-- Solve Rank Column -->
|
<!-- Solve Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="text-3xl sm:text-4xl font-black">
|
<div
|
||||||
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||||
|
>
|
||||||
#{statsData.solveRank}
|
#{statsData.solveRank}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs sm:text-sm opacity-90 mt-1">
|
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
||||||
You were the {toOrdinal(statsData.solveRank)} person to solve today
|
You were the {toOrdinal(statsData.solveRank)} person to solve
|
||||||
|
today
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Guess Rank Column -->
|
<!-- Guess Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="text-3xl sm:text-4xl font-black">
|
<div
|
||||||
{Math.round(
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||||
((statsData.totalSolves - statsData.guessRank + 1) /
|
>
|
||||||
statsData.totalSolves) *
|
{toOrdinal(statsData.guessRank)}
|
||||||
100
|
|
||||||
)}%
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs sm:text-sm opacity-90 mt-1">
|
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
||||||
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
|
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
|
||||||
total solves
|
{statsData.totalSolves === 1
|
||||||
|
? "solve"
|
||||||
|
: "solves"}{statsData.tiedCount > 0
|
||||||
|
? `, tied with ${statsData.tiedCount} ${statsData.tiedCount === 1 ? "other" : "others"}`
|
||||||
|
: ""}.<br />
|
||||||
|
{#if statsData.percentile <= 25}
|
||||||
|
<span class="font-bold">
|
||||||
|
(Top {statsData.percentile}%)
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Average Column -->
|
<!-- Average Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="text-3xl sm:text-4xl font-black">
|
<div
|
||||||
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||||
|
>
|
||||||
{statsData.averageGuesses}
|
{statsData.averageGuesses}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs sm:text-sm opacity-90 mt-1">
|
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
||||||
People guessed correctly after {statsData.averageGuesses}
|
People solved after {statsData.averageGuesses}
|
||||||
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on average
|
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on
|
||||||
</div>
|
average
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Container>
|
||||||
{:else if !statsSubmitted}
|
{:else if !statsSubmitted}
|
||||||
<div class="mt-6 text-sm opacity-80">Submitting stats...</div>
|
<Container
|
||||||
|
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
||||||
|
>
|
||||||
|
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||||
|
</Container>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
||||||
|
|
||||||
export const session = sqliteTable('session', {
|
export const session = sqliteTable('session', {
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||||
<script
|
<script
|
||||||
defer
|
defer
|
||||||
src="https://umami.snail.city/script.js"
|
src="https://umami.snail.city/script.js"
|
||||||
|
|||||||
@@ -91,13 +91,20 @@ export const actions: Actions = {
|
|||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
const guessRank = betterGuesses + 1;
|
const guessRank = betterGuesses + 1;
|
||||||
|
|
||||||
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
|
|
||||||
// Average guesses
|
// Average guesses
|
||||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||||
|
|
||||||
|
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
||||||
|
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||||
|
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
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 WinScreen from "$lib/components/WinScreen.svelte";
|
import WinScreen from "$lib/components/WinScreen.svelte";
|
||||||
import Feedback from "$lib/components/Feedback.svelte";
|
import Credits from "$lib/components/Credits.svelte";
|
||||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||||
|
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||||
import { getGrade } from "$lib/utils/game";
|
import { getGrade } from "$lib/utils/game";
|
||||||
|
|
||||||
interface Guess {
|
interface Guess {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
testamentMatch: boolean;
|
testamentMatch: boolean;
|
||||||
sectionMatch: boolean;
|
sectionMatch: boolean;
|
||||||
adjacent: boolean;
|
adjacent: boolean;
|
||||||
|
firstLetterMatch: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -31,6 +32,8 @@
|
|||||||
|
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let isDev = $state(false);
|
let isDev = $state(false);
|
||||||
|
let chapterGuessCompleted = $state(false);
|
||||||
|
let chapterCorrect = $state(false);
|
||||||
|
|
||||||
let anonymousId = $state("");
|
let anonymousId = $state("");
|
||||||
let statsSubmitted = $state(false);
|
let statsSubmitted = $state(false);
|
||||||
@@ -39,6 +42,8 @@
|
|||||||
guessRank: number;
|
guessRank: number;
|
||||||
totalSolves: number;
|
totalSolves: number;
|
||||||
averageGuesses: number;
|
averageGuesses: number;
|
||||||
|
tiedCount: number;
|
||||||
|
percentile: 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)));
|
||||||
@@ -49,14 +54,22 @@
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
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(guesses.length, getBookById(correctBookId)?.popularity ?? 0)
|
? guesses.length === 1 && chapterCorrect
|
||||||
: ""
|
? "S++"
|
||||||
|
: getGrade(
|
||||||
|
guesses.length,
|
||||||
|
getBookById(correctBookId)?.popularity ?? 0,
|
||||||
|
)
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
let blurChapter = $derived(
|
||||||
|
isWon && guesses.length === 1 && !chapterGuessCompleted,
|
||||||
);
|
);
|
||||||
|
|
||||||
function getBookById(id: string): BibleBook | undefined {
|
function getBookById(id: string): BibleBook | undefined {
|
||||||
@@ -69,6 +82,11 @@
|
|||||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFirstLetter(bookName: string): string {
|
||||||
|
const match = bookName.match(/[a-zA-Z]/);
|
||||||
|
return match ? match[0] : bookName[0];
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -80,10 +98,27 @@
|
|||||||
|
|
||||||
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(bookId, correctBookId);
|
||||||
|
|
||||||
|
// Special case: if correct book is in the Epistles + starts with "1",
|
||||||
|
// any guess starting with "1" counts as first letter match
|
||||||
|
const correctIsEpistlesWithNumber =
|
||||||
|
(correctBook.section === "Pauline Epistles" ||
|
||||||
|
correctBook.section === "General Epistles") &&
|
||||||
|
correctBook.name[0] === "1";
|
||||||
|
const guessIsEpistlesWithNumber =
|
||||||
|
(book.section === "Pauline Epistles" ||
|
||||||
|
book.section === "General Epistles") &&
|
||||||
|
book.name[0] === "1";
|
||||||
|
|
||||||
|
const firstLetterMatch =
|
||||||
|
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||||
|
? true
|
||||||
|
: getFirstLetter(book.name).toUpperCase() ===
|
||||||
|
getFirstLetter(correctBook.name).toUpperCase();
|
||||||
|
|
||||||
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}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (guesses.length === 0) {
|
if (guesses.length === 0) {
|
||||||
@@ -104,6 +139,7 @@
|
|||||||
testamentMatch,
|
testamentMatch,
|
||||||
sectionMatch,
|
sectionMatch,
|
||||||
adjacent,
|
adjacent,
|
||||||
|
firstLetterMatch,
|
||||||
},
|
},
|
||||||
...guesses,
|
...guesses,
|
||||||
];
|
];
|
||||||
@@ -119,7 +155,8 @@
|
|||||||
|
|
||||||
// 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 = window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
|
const r =
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
@@ -142,6 +179,17 @@
|
|||||||
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";
|
||||||
|
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||||
|
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||||
|
if (chapterGuessCompleted) {
|
||||||
|
const saved = localStorage.getItem(chapterGuessKey);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
const match = dailyVerse.reference.match(/\s(\d+):/);
|
||||||
|
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||||
|
chapterCorrect = data.selectedChapter === correctChapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -164,11 +212,29 @@
|
|||||||
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);
|
||||||
|
|
||||||
|
// Apply same first letter logic as in submitGuess
|
||||||
|
const correctIsEpistlesWithNumber =
|
||||||
|
(correctBook.section === "Pauline Epistles" ||
|
||||||
|
correctBook.section === "General Epistles") &&
|
||||||
|
correctBook.name[0] === "1";
|
||||||
|
const guessIsEpistlesWithNumber =
|
||||||
|
(book.section === "Pauline Epistles" ||
|
||||||
|
book.section === "General Epistles") &&
|
||||||
|
book.name[0] === "1";
|
||||||
|
|
||||||
|
const firstLetterMatch =
|
||||||
|
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||||
|
? true
|
||||||
|
: getFirstLetter(book.name).toUpperCase() ===
|
||||||
|
getFirstLetter(correctBook.name).toUpperCase();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book,
|
book,
|
||||||
testamentMatch,
|
testamentMatch,
|
||||||
sectionMatch,
|
sectionMatch,
|
||||||
adjacent,
|
adjacent,
|
||||||
|
firstLetterMatch,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -178,7 +244,7 @@
|
|||||||
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)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,7 +269,7 @@
|
|||||||
(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);
|
||||||
@@ -213,7 +279,7 @@
|
|||||||
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);
|
||||||
@@ -257,7 +323,7 @@
|
|||||||
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);
|
||||||
@@ -303,13 +369,13 @@
|
|||||||
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.length == 1 ? "guess" : "guesses"})`,
|
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
||||||
`${emojis}`,
|
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
||||||
siteUrl,
|
siteUrl,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
@@ -368,25 +434,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLocalStorage() {
|
|
||||||
if (!browser) return;
|
|
||||||
// Clear all bibdle-related localStorage items
|
|
||||||
const keysToRemove: string[] = [];
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key && key.startsWith("bibdle-")) {
|
|
||||||
keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
|
||||||
// Reload the page to reset state
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<!-- <title>Bibdle — A daily bible game{isDev ? " (dev)" : ""}</title> -->
|
|
||||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||||
<!-- <meta
|
<!-- <meta
|
||||||
name="description"
|
name="description"
|
||||||
@@ -408,7 +458,8 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VerseDisplay {data} {isWon} />
|
<div class="flex flex-col gap-6">
|
||||||
|
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||||
|
|
||||||
{#if !isWon}
|
{#if !isWon}
|
||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||||
@@ -422,21 +473,33 @@
|
|||||||
bind:copied
|
bind:copied
|
||||||
{statsSubmitted}
|
{statsSubmitted}
|
||||||
guessCount={guesses.length}
|
guessCount={guesses.length}
|
||||||
|
reference={dailyVerse.reference}
|
||||||
|
onChapterGuessCompleted={() => {
|
||||||
|
chapterGuessCompleted = true;
|
||||||
|
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
const match =
|
||||||
|
dailyVerse.reference.match(/\s(\d+):/);
|
||||||
|
const correctChapter = match
|
||||||
|
? parseInt(match[1], 10)
|
||||||
|
: 1;
|
||||||
|
chapterCorrect =
|
||||||
|
data.selectedChapter === correctChapter;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<CountdownTimer />
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<GuessesTable {guesses} {correctBookId} />
|
<GuessesTable {guesses} {correctBookId} />
|
||||||
|
|
||||||
{#if isWon}
|
{#if isWon}
|
||||||
<Feedback />
|
<Credits />
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{#if isDev}
|
{#if isDev}
|
||||||
<button
|
<DevButtons />
|
||||||
onclick={clearLocalStorage}
|
|
||||||
class="mt-4 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-bold transition-colors"
|
|
||||||
>
|
|
||||||
Clear LocalStorage
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -48,13 +48,20 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
const guessRank = betterGuesses + 1;
|
const guessRank = betterGuesses + 1;
|
||||||
|
|
||||||
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
|
|
||||||
// Average guesses
|
// Average guesses
|
||||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||||
|
|
||||||
|
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
||||||
|
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||||
|
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error submitting completion:', err);
|
console.error('Error submitting completion:', err);
|
||||||
@@ -105,13 +112,20 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
const guessRank = betterGuesses + 1;
|
const guessRank = betterGuesses + 1;
|
||||||
|
|
||||||
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
|
|
||||||
// Average guesses
|
// Average guesses
|
||||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||||
|
|
||||||
|
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
||||||
|
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||||
|
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching stats:', err);
|
console.error('Error fetching stats:', err);
|
||||||
|
|||||||
145
src/routes/feed.xml/+server.ts
Normal file
145
src/routes/feed.xml/+server.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyVerses } from '$lib/server/db/schema';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Helper: Escape XML special characters
|
||||||
|
function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format YYYY-MM-DD to RFC 822 date string
|
||||||
|
function formatRFC822(dateStr: string): string {
|
||||||
|
// Parse date in America/New_York timezone (EST/EDT)
|
||||||
|
// Assuming midnight ET
|
||||||
|
const date = new Date(dateStr + 'T00:00:00-05:00');
|
||||||
|
return date.toUTCString().replace('GMT', 'EST');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format YYYY-MM-DD to readable date
|
||||||
|
function formatReadableDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'America/New_York'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
|
||||||
|
function formatVerseText(text: string): string {
|
||||||
|
let formatted = text;
|
||||||
|
|
||||||
|
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
|
||||||
|
const pairs: [string, string][] = [
|
||||||
|
['(', ')'],
|
||||||
|
['[', ']'],
|
||||||
|
['{', '}'],
|
||||||
|
['"', '"'],
|
||||||
|
["'", "'"],
|
||||||
|
['\u201C', '\u201D'], // " "
|
||||||
|
['\u2018', '\u2019'] // ' '
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if text starts with opening punctuation without closing
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||||
|
formatted += '...' + close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if text ends with closing punctuation without opening
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||||
|
formatted = open + '...' + formatted;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if text contains unbalanced opening quotes (not at start) without closing
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
|
||||||
|
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
|
||||||
|
if (openCount > closeCount) {
|
||||||
|
formatted += close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
|
||||||
|
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
// Replace trailing punctuation with ellipsis
|
||||||
|
// Preserve closing quotes/brackets that may have been added
|
||||||
|
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
// Query last 30 verses, ordered by date descending
|
||||||
|
const verses = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyVerses)
|
||||||
|
.orderBy(desc(dailyVerses.date))
|
||||||
|
.limit(30);
|
||||||
|
|
||||||
|
// Generate ETag based on latest verse date
|
||||||
|
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
|
||||||
|
|
||||||
|
// Check if client has cached version
|
||||||
|
if (request.headers.get('If-None-Match') === etag) {
|
||||||
|
return new Response(null, { status: 304 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get site URL from environment or use default
|
||||||
|
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
|
||||||
|
|
||||||
|
// Build RSS XML
|
||||||
|
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
|
||||||
|
|
||||||
|
const items = verses
|
||||||
|
.map(
|
||||||
|
(verse) => `
|
||||||
|
<item>
|
||||||
|
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
|
||||||
|
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
|
||||||
|
<link>${SITE_URL}</link>
|
||||||
|
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
|
||||||
|
<pubDate>${formatRFC822(verse.date)}</pubDate>
|
||||||
|
</item>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Bibdle</title>
|
||||||
|
<link>${SITE_URL}</link>
|
||||||
|
<description>A daily Bible game</description>
|
||||||
|
<language>en-us</language>
|
||||||
|
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||||
|
<ttl>720</ttl>${items}
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||||
|
ETag: etag
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('RSS feed generation error:', error);
|
||||||
|
return new Response('Internal Server Error', { 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>
|
||||||
23
todo.md
23
todo.md
@@ -1,6 +1,9 @@
|
|||||||
# in progress
|
# in progress
|
||||||
|
|
||||||
- root menu: classic / imposter mode / impossible mode (complete today's classic and imposter modes to unlock)
|
- 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?
|
||||||
|
|
||||||
# todo
|
# todo
|
||||||
|
|
||||||
@@ -14,8 +17,12 @@
|
|||||||
|
|
||||||
- classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible.
|
- classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible.
|
||||||
- imposter mode: out of four options, identify the verse that is not in the Bible
|
- imposter mode: out of four options, identify the verse that is not in the Bible
|
||||||
|
- OR out of four options, identify the verse that is not in the same book as the other three options
|
||||||
|
- OR, out of four options, drag them into the
|
||||||
- impossible mode: identify which book of the bible the verse is from in less than three guesses.
|
- impossible mode: identify which book of the bible the verse is from in less than three guesses.
|
||||||
|
|
||||||
|
- The gambling aspect of hoping you get a verse you already know is VERY strong
|
||||||
|
|
||||||
- add login + saved stats + streak etc.
|
- add login + saved stats + streak etc.
|
||||||
|
|
||||||
- add deuterocanonical books
|
- add deuterocanonical books
|
||||||
@@ -47,6 +54,20 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
|
|
||||||
# done
|
# done
|
||||||
|
|
||||||
|
## 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 "..."
|
||||||
|
|
||||||
|
## january 4th
|
||||||
|
|
||||||
|
- For bonus points: guess the verse/psalm number
|
||||||
|
- major UI styling revamp
|
||||||
|
|
||||||
|
## december 30th
|
||||||
|
|
||||||
|
- merged the embeddings/similarity route into production
|
||||||
|
|
||||||
## december 27th
|
## december 27th
|
||||||
|
|
||||||
- add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate)
|
- add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate)
|
||||||
|
|||||||
Reference in New Issue
Block a user