This commit is contained in:
George Powell
2025-12-16 18:46:53 -05:00
parent 530291d271
commit 427d1dc918
7 changed files with 610 additions and 282 deletions

View File

@@ -1 +0,0 @@
DATABASE_URL=local.db

18
bibdle.service Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=Bibdle SvelteKit App
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
Requires=bibdle.socket
After=network-online.target bibdle.socket
[Service]
Environment=NODE_ENV=production
Environment=ORIGIN=https://bibdle.orthodox.cafe
Environment=DATABASE_URL=local.db
Environment=IDLE_TIMEOUT=60
WorkingDirectory=/home/george/projects/bibdle
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

5
bibdle.socket Normal file
View File

@@ -0,0 +1,5 @@
[Socket]
ListenStream=5173
[Install]
WantedBy=sockets.target

267
deploy.txt Normal file
View File

@@ -0,0 +1,267 @@
To generate a standalone Node server, use [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node).
## Usage
Install with `npm i -D @sveltejs/adapter-node`, then add the adapter to your `svelte.config.js`:
```js
// @errors: 2307
/// file: svelte.config.js
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;
```
## Deploying
First, build your app with `npm run build`. This will create the production server in the output directory specified in the adapter options, defaulting to `build`.
You will need the output directory, the project's `package.json`, and the production dependencies in `node_modules` to run the application. Production dependencies can be generated by copying the `package.json` and `package-lock.json` and then running `npm ci --omit dev` (you can skip this step if your app doesn't have any dependencies). You can then start your app with this command:
```sh
node build
```
Development dependencies will be bundled into your app using [Rollup](https://rollupjs.org). To control whether a given package is bundled or externalised, place it in `devDependencies` or `dependencies` respectively in your `package.json`.
### Compressing responses
You will typically want to compress responses coming from the server. If you're already deploying your server behind a reverse proxy for SSL or load balancing, it typically results in better performance to also handle compression at that layer since Node.js is single-threaded.
However, if you're building a [custom server](#Custom-server) and do want to add a compression middleware there, note that we would recommend using [`@polka/compression`](https://www.npmjs.com/package/@polka/compression) since SvelteKit streams responses and the more popular `compression` package does not support streaming and may cause errors when used.
## Environment variables
In `dev` and `preview`, SvelteKit will read environment variables from your `.env` file (or `.env.local`, or `.env.[mode]`, [as determined by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files).)
In production, `.env` files are _not_ automatically loaded. To do so, install `dotenv` in your project...
```sh
npm install dotenv
```
...and invoke it before running the built app:
```sh
node +++-r dotenv/config+++ build
```
If you use Node.js v20.6+, you can use the [`--env-file`](https://nodejs.org/en/learn/command-line/how-to-read-environment-variables-from-nodejs) flag instead:
```sh
node +++--env-file=.env+++ build
```
### `PORT`, `HOST` and `SOCKET_PATH`
By default, the server will accept connections on `0.0.0.0` using port 3000. These can be customised with the `PORT` and `HOST` environment variables:
```sh
HOST=127.0.0.1 PORT=4000 node build
```
Alternatively, the server can be configured to accept connections on a specified socket path. When this is done using the `SOCKET_PATH` environment variable, the `HOST` and `PORT` environment variables will be disregarded.
```sh
SOCKET_PATH=/tmp/socket node build
```
### `ORIGIN`, `PROTOCOL_HEADER`, `HOST_HEADER`, and `PORT_HEADER`
HTTP doesn't give SvelteKit a reliable way to know the URL that is currently being requested. The simplest way to tell SvelteKit where the app is being served is to set the `ORIGIN` environment variable:
```sh
ORIGIN=https://my.site node build
# or e.g. for local previewing and testing
ORIGIN=http://localhost:3000 node build
```
With this, a request for the `/stuff` pathname will correctly resolve to `https://my.site/stuff`. Alternatively, you can specify headers that tell SvelteKit about the request protocol and host, from which it can construct the origin URL:
```sh
PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host node build
```
> [!NOTE] [`x-forwarded-proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) and [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host) are de facto standard headers that forward the original protocol and host if you're using a reverse proxy (think load balancers and CDNs). You should only set these variables if your server is behind a trusted reverse proxy; otherwise, it'd be possible for clients to spoof these headers.
>
> If you're hosting your proxy on a non-standard port and your reverse proxy supports `x-forwarded-port`, you can also set `PORT_HEADER=x-forwarded-port`.
If `adapter-node` can't correctly determine the URL of your deployment, you may experience this error when using [form actions](form-actions):
> [!NOTE] Cross-site POST form submissions are forbidden
### `ADDRESS_HEADER` and `XFF_DEPTH`
The [`RequestEvent`](@sveltejs-kit#RequestEvent) object passed to hooks and endpoints includes an `event.getClientAddress()` function that returns the client's IP address. By default this is the connecting `remoteAddress`. If your server is behind one or more proxies (such as a load balancer), this value will contain the innermost proxy's IP address rather than the client's, so we need to specify an `ADDRESS_HEADER` to read the address from:
```sh
ADDRESS_HEADER=True-Client-IP node build
```
> [!NOTE] Headers can easily be spoofed. As with `PROTOCOL_HEADER` and `HOST_HEADER`, you should [know what you're doing](https://adam-p.ca/blog/2022/03/x-forwarded-for/) before setting these.
If the `ADDRESS_HEADER` is `X-Forwarded-For`, the header value will contain a comma-separated list of IP addresses. The `XFF_DEPTH` environment variable should specify how many trusted proxies sit in front of your server. E.g. if there are three trusted proxies, proxy 3 will forward the addresses of the original connection and the first two proxies:
```
<client address>, <proxy 1 address>, <proxy 2 address>
```
Some guides will tell you to read the left-most address, but this leaves you [vulnerable to spoofing](https://adam-p.ca/blog/2022/03/x-forwarded-for/):
```
<spoofed address>, <client address>, <proxy 1 address>, <proxy 2 address>
```
We instead read from the _right_, accounting for the number of trusted proxies. In this case, we would use `XFF_DEPTH=3`.
> [!NOTE] If you need to read the left-most address instead (and don't care about spoofing) — for example, to offer a geolocation service, where it's more important for the IP address to be _real_ than _trusted_, you can do so by inspecting the `x-forwarded-for` header within your app.
### `BODY_SIZE_LIMIT`
The maximum request body size to accept in bytes including while streaming. The body size can also be specified with a unit suffix for kilobytes (`K`), megabytes (`M`), or gigabytes (`G`). For example, `512K` or `1M`. Defaults to 512kb. You can disable this option with a value of `Infinity` (0 in older versions of the adapter) and implement a custom check in [`handle`](hooks#Server-hooks-handle) if you need something more advanced.
### `SHUTDOWN_TIMEOUT`
The number of seconds to wait before forcefully closing any remaining connections after receiving a `SIGTERM` or `SIGINT` signal. Defaults to `30`. Internally the adapter calls [`closeAllConnections`](https://nodejs.org/api/http.html#servercloseallconnections). See [Graceful shutdown](#Graceful-shutdown) for more details.
### `IDLE_TIMEOUT`
When using systemd socket activation, `IDLE_TIMEOUT` specifies the number of seconds after which the app is automatically put to sleep when receiving no requests. If not set, the app runs continuously. See [Socket activation](#Socket-activation) for more details.
## Options
The adapter can be configured with various options:
```js
// @errors: 2307
/// file: svelte.config.js
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
// default options are shown
out: 'build',
precompress: true,
envPrefix: ''
})
}
};
export default config;
```
### out
The directory to build the server to. It defaults to `build` — i.e. `node build` would start the server locally after it has been created.
### precompress
Enables precompressing using gzip and brotli for assets and prerendered pages. It defaults to `true`.
### envPrefix
If you need to change the name of the environment variables used to configure the deployment (for example, to deconflict with environment variables you don't control), you can specify a prefix:
```js
envPrefix: 'MY_CUSTOM_';
```
```sh
MY_CUSTOM_HOST=127.0.0.1 \
MY_CUSTOM_PORT=4000 \
MY_CUSTOM_ORIGIN=https://my.site \
node build
```
## Graceful shutdown
By default `adapter-node` gracefully shuts down the HTTP server when a `SIGTERM` or `SIGINT` signal is received. It will:
1. reject new requests ([`server.close`](https://nodejs.org/api/http.html#serverclosecallback))
2. wait for requests that have already been made but not received a response yet to finish and close connections once they become idle ([`server.closeIdleConnections`](https://nodejs.org/api/http.html#servercloseidleconnections))
3. and finally, close any remaining connections that are still active after [`SHUTDOWN_TIMEOUT`](#Environment-variables-SHUTDOWN_TIMEOUT) seconds. ([`server.closeAllConnections`](https://nodejs.org/api/http.html#servercloseallconnections))
> [!NOTE] If you want to customize this behaviour you can use a [custom server](#Custom-server).
You can listen to the `sveltekit:shutdown` event which is emitted after the HTTP server has closed all connections. Unlike Node's `exit` event, the `sveltekit:shutdown` event supports asynchronous operations and is always emitted when all connections are closed even if the server has dangling work such as open database connections.
```js
// @errors: 2304
process.on('sveltekit:shutdown', async (reason) => {
await jobs.stop();
await db.close();
});
```
The parameter `reason` has one of the following values:
- `SIGINT` - shutdown was triggered by a `SIGINT` signal
- `SIGTERM` - shutdown was triggered by a `SIGTERM` signal
- `IDLE` - shutdown was triggered by [`IDLE_TIMEOUT`](#Environment-variables-IDLE_TIMEOUT)
## Socket activation
Most Linux operating systems today use a modern process manager called systemd to start the server and run and manage services. You can configure your server to allocate a socket and start and scale your app on demand. This is called [socket activation](https://0pointer.de/blog/projects/socket-activated-containers.html). In this case, the OS will pass two environment variables to your app — `LISTEN_PID` and `LISTEN_FDS`. The adapter will then listen on file descriptor 3 which refers to a systemd socket unit that you will have to create.
> [!NOTE] You can still use [`envPrefix`](#Options-envPrefix) with systemd socket activation. `LISTEN_PID` and `LISTEN_FDS` are always read without a prefix.
To take advantage of socket activation follow these steps.
1. Run your app as a [systemd service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html). It can either run directly on the host system or inside a container (using Docker or a systemd portable service for example). If you additionally pass an [`IDLE_TIMEOUT`](#Environment-variables-IDLE_TIMEOUT) environment variable to your app it will gracefully shutdown if there are no requests for `IDLE_TIMEOUT` seconds. systemd will automatically start your app again when new requests are coming in.
```ini
/// file: /etc/systemd/system/myapp.service
[Service]
Environment=NODE_ENV=production IDLE_TIMEOUT=60
ExecStart=/usr/bin/node /usr/bin/myapp/build
```
2. Create an accompanying [socket unit](https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html). The adapter only accepts a single socket.
```ini
/// file: /etc/systemd/system/myapp.socket
[Socket]
ListenStream=3000
[Install]
WantedBy=sockets.target
```
3. Make sure systemd has recognised both units by running `sudo systemctl daemon-reload`. Then enable the socket on boot and start it immediately using `sudo systemctl enable --now myapp.socket`. The app will then automatically start once the first request is made to `localhost:3000`.
## Custom server
The adapter creates two files in your build directory — `index.js` and `handler.js`. Running `index.js` — e.g. `node build`, if you use the default build directory — will start a server on the configured port.
Alternatively, you can import the `handler.js` file, which exports a handler suitable for use with [Express](https://github.com/expressjs/express), [Connect](https://github.com/senchalabs/connect) or [Polka](https://github.com/lukeed/polka) (or even just the built-in [`http.createServer`](https://nodejs.org/dist/latest/docs/api/http.html#httpcreateserveroptions-requestlistener)) and set up your own server:
```js
// @errors: 2307 7006
/// file: my-server.js
import { handler } from './build/handler.js';
import express from 'express';
const app = express();
// add a route that lives separately from the SvelteKit app
app.get('/healthcheck', (req, res) => {
res.end('ok');
});
// let SvelteKit handle everything else, including serving prerendered pages and static assets
app.use(handler);
app.listen(3000, () => {
console.log('listening on port 3000');
});
```

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@@ -1,316 +1,350 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible"; import { bibleBooks, type BibleBook } from "$lib/types/bible";
interface Guess { interface Guess {
book: BibleBook; book: BibleBook;
testamentMatch: boolean; testamentMatch: boolean;
sectionMatch: boolean; sectionMatch: boolean;
adjacent: boolean; adjacent: boolean;
} }
import type { PageProps } from "./$types"; import type { PageProps } from "./$types";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let dailyVerse = $derived(data.dailyVerse); let dailyVerse = $derived(data.dailyVerse);
let correctBookId = $derived(data.correctBookId); let correctBookId = $derived(data.correctBookId);
let guesses = $state<Guess[]>([]); let guesses = $state<Guess[]>([]);
let searchQuery = $state(""); let searchQuery = $state("");
let copied = $state(false); let copied = $state(false);
let filteredBooks = $derived( let filteredBooks = $derived(
bibleBooks.filter((book) => bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()) book.name.toLowerCase().includes(searchQuery.toLowerCase()),
) ),
); );
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) ? getGrade(
: "" guesses.length,
); getBookById(correctBookId)?.popularity ?? 0,
)
: "",
);
function getBookById(id: string): BibleBook | undefined { function getBookById(id: string): BibleBook | undefined {
return bibleBooks.find((b) => b.id === id); return bibleBooks.find((b) => b.id === id);
} }
function isAdjacent(id1: string, id2: string): boolean { function isAdjacent(id1: string, id2: string): boolean {
const b1 = getBookById(id1); const b1 = getBookById(id1);
const b2 = getBookById(id2); const b2 = getBookById(id2);
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1); return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
} }
function submitGuess(bookId: string) { function submitGuess(bookId: string) {
if (guesses.some((g) => g.book.id === bookId)) return; if (guesses.some((g) => g.book.id === bookId)) return;
const book = getBookById(bookId); const book = getBookById(bookId);
if (!book) return; if (!book) return;
const correctBook = getBookById(correctBookId); const correctBook = getBookById(correctBookId);
if (!correctBook) return; if (!correctBook) return;
const testamentMatch = book.testament === correctBook.testament; const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section; const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(book.id, correctBookId); const adjacent = isAdjacent(book.id, correctBookId);
console.log( console.log(
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}` `Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
); );
guesses = [ guesses = [
{ {
book, book,
testamentMatch, testamentMatch,
sectionMatch, sectionMatch,
adjacent, adjacent,
}, },
...guesses, ...guesses,
]; ];
searchQuery = ""; searchQuery = "";
} }
function getGrade(numGuesses: number, popularity: number): string { function getGrade(numGuesses: number, popularity: number): string {
const difficulty = 14 - popularity; const difficulty = 14 - popularity;
const performanceScore = Math.max(0, 10 - numGuesses); const performanceScore = Math.max(0, 10 - numGuesses);
const totalScore = performanceScore + difficulty * 0.8; const totalScore = performanceScore + difficulty * 0.8;
if (totalScore >= 14) return "🟢 S"; if (totalScore >= 14) return "🟢 S";
if (totalScore >= 11) return "🟢 A"; if (totalScore >= 11) return "🟢 A";
if (totalScore >= 8) return "🟡 B"; if (totalScore >= 8) return "🟡 B";
if (totalScore >= 5) return "🟠 C"; if (totalScore >= 5) return "🟠 C";
return "🔴 C-"; return "🔴 C-";
} }
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
const key = `bibdle-guesses-${dailyVerse.date}`; const key = `bibdle-guesses-${dailyVerse.date}`;
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (saved) { if (saved) {
let savedIds: string[] = JSON.parse(saved); let savedIds: string[] = JSON.parse(saved);
savedIds = Array.from(new Set(savedIds)); savedIds = Array.from(new Set(savedIds));
guesses = savedIds.map((bookId: string) => { guesses = savedIds.map((bookId: string) => {
const book = getBookById(bookId)!; const book = getBookById(bookId)!;
const correctBook = getBookById(correctBookId)!; const correctBook = getBookById(correctBookId)!;
const testamentMatch = book.testament === correctBook.testament; const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section; const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(bookId, correctBookId); const adjacent = isAdjacent(bookId, correctBookId);
return { return {
book, book,
testamentMatch, testamentMatch,
sectionMatch, sectionMatch,
adjacent, adjacent,
}; };
}); });
} }
}); });
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
localStorage.setItem( localStorage.setItem(
`bibdle-guesses-${dailyVerse.date}`, `bibdle-guesses-${dailyVerse.date}`,
JSON.stringify(guesses.map((g) => g.book.id)) JSON.stringify(guesses.map((g) => g.book.id)),
); );
}); });
async function share() { async function share() {
if (!browser) return; if (!browser) return;
const emojis = guesses const emojis = guesses
.slice() .slice()
.reverse() .reverse()
.map((guess) => { .map((guess) => {
if (guess.book.id === correctBookId) return "✅"; if (guess.book.id === correctBookId) return "✅";
if (guess.adjacent) return "‼️"; if (guess.adjacent) return "‼️";
if (guess.sectionMatch) return "🟩"; if (guess.sectionMatch) return "🟩";
if (guess.testamentMatch) return "🟧"; if (guess.testamentMatch) return "🟧";
return "🟥"; return "🟥";
}) })
.join(""); .join("");
const dateFormatter = new Intl.DateTimeFormat("en-US", { const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}); });
const formattedDate = dateFormatter.format( const formattedDate = dateFormatter.format(
new Date(`${dailyVerse.date}T00:00:00`) new Date(`${dailyVerse.date}T00:00:00`),
); );
const siteUrl = window.location.origin; const siteUrl = window.location.origin;
const shareText = [ const shareText = [
`📖 Bibdle | ${formattedDate} 📖`, `📖 Bibdle | ${formattedDate} 📖`,
`${grade} (${guesses.length} guesses)`, `${grade} (${guesses.length} guesses)`,
emojis, `${emojis}\n`,
siteUrl, siteUrl,
].join("\n"); ].join("\n");
try { try {
await navigator.clipboard.writeText(shareText); if ("share" in navigator) {
} catch (err) { await (navigator as any).share({ text: shareText });
console.error("Share failed:", err); } else {
} await (navigator as any).clipboard.writeText(shareText);
} }
} catch (err) {
console.error("Share failed:", err);
throw err;
}
}
function handleShare() { function handleShare() {
if (copied || !browser) return; if (copied || !browser) return;
copied = true; copied = true;
share() share()
.then(() => { .then(() => {
setTimeout(() => { setTimeout(() => {
copied = false; copied = false;
}, 5000); }, 5000);
}) })
.catch(() => { .catch(() => {
copied = false; copied = false;
}); });
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Bibdle</title> <title>Bibdle</title>
</svelte:head> </svelte:head>
<div class="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100 py-8"> <div class="min-h-dvh bg-linear-to-br from-blue-50 to-indigo-100 py-8">
<div <div
class="max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl mx-auto px-2 sm:px-4" class="pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl mx-auto px-2 sm:px-4"
> >
<h1 <h1
class="text-3xl md:text-4xl font-bold text-center text-gray-800 mb-8 sm:mb-12 drop-shadow-lg" class="text-3xl md:text-4xl font-bold text-center text-gray-800 p-8 sm:p-12 drop-shadow-lg"
> >
Bibdle Bibdle
</h1> </h1>
<!-- Verse Display --> <!-- Verse Display -->
<div <div
class="bg-white rounded-2xl shadow-xl p-8 sm:p-12 mb-8 sm:mb-12 max-w-full sm:max-w-2xl md:max-w-3xl mx-auto" class="bg-white rounded-2xl shadow-xl p-8 sm:p-12 mb-8 sm:mb-12 max-w-full sm:max-w-2xl md:max-w-3xl mx-auto"
> >
<blockquote <blockquote
class="text-xl sm:text-2xl leading-relaxed text-gray-700 italic text-center" class="text-xl sm:text-2xl leading-relaxed text-gray-700 italic text-center"
> >
&ldquo;{dailyVerse.verseText}&rdquo; {dailyVerse.verseText}
</blockquote> </blockquote>
{#if !isWon} {#if isWon}
<!-- <p class="text-center text-sm text-gray-500 mt-4 font-medium"> <p class="text-center text-lg text-green-600 font-bold mt-4">
Guess the book! {dailyVerse.reference}
</p> --> </p>
{:else} {/if}
<p class="text-center text-lg text-green-600 font-bold mt-4"> </div>
{dailyVerse.reference}
</p>
{/if}
</div>
{#if !isWon} {#if !isWon}
<!-- Book Search --> <!-- Book Search -->
<div class="mb-12"> <div class="mb-12">
<input <input
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Type to search books (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 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"
/> onkeydown={(e) => {
{#if searchQuery && filteredBooks.length > 0} if (e.key === "Enter" && filteredBooks.length > 0) {
<ul submitGuess(filteredBooks[0].id);
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-2xl shadow-lg" }
> }}
{#each filteredBooks as book} />
<li> {#if searchQuery && filteredBooks.length > 0}
<button <ul
class="w-full p-4 sm:p-5 text-left hover:bg-blue-50 hover:text-blue-700 transition-all border-b border-gray-100 last:border-b-0 flex items-center" class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-2xl shadow-lg"
onclick={() => submitGuess(book.id)} >
> {#each filteredBooks as book}
<span class="font-semibold">{book.name}</span> <li>
<span class="ml-auto text-sm opacity-75" <button
>({book.testament.toUpperCase()})</span class="w-full p-4 sm:p-5 text-left 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)}
</button> >
</li> <span class="font-semibold"
{/each} >{book.name}</span
</ul> >
{:else if searchQuery} <span class="ml-auto text-sm opacity-75"
<p class="mt-4 text-center text-gray-500 p-8">No books found</p> >({book.testament.toUpperCase()})</span
{/if} >
</div> </button>
{:else} </li>
<div {/each}
class="mb-12 p-8 sm:p-12 bg-linear-to-r from-green-400 to-green-600 text-white rounded-2xl shadow-2xl text-center" </ul>
in:fade={{ delay: 500 }} {:else if searchQuery}
> <p class="mt-4 text-center text-gray-500 p-8">
<h2 class="text-4xl font-black mb-4 drop-shadow-lg"> No books found
🎉 Congratulations! 🎉 </p>
</h2> {/if}
<p class="text-lg sm:text-xl md:text-2xl mb-8"> </div>
The verse is from <span {:else}
class="font-black text-xl sm:text-2xl md:text-3xl" <div
>{getBookById(correctBookId)?.name}</span class="mb-12 p-8 sm:p-12 max-w-full sm:max-w-2xl md:max-w-3xl mx-auto bg-linear-to-r from-green-400 to-green-600 text-white rounded-2xl shadow-2xl text-center"
> in:fade={{ delay: 500 }}
</p> >
<p class="text-xl opacity-90">{dailyVerse.reference}</p> <h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg">
<p 🎉 Congratulations! 🎉
class="text-2xl font-bold mt-6 p-2 bg-black/20 rounded-lg inline-block" </h2>
> <p class="text-lg sm:text-xl md:text-2xl mb-4">
Your grade: {grade} The verse is from <span
</p> class="font-black text-xl sm:text-2xl md:text-3xl"
<button >{getBookById(correctBookId)?.name}</span
onclick={handleShare} >
class={`mt-4 text-2xl font-bold p-2 ${ </p>
copied <p class="text-xl opacity-90">{dailyVerse.reference}</p>
? "bg-green-400/50 hover:bg-green-500/60" <p
: "bg-white/20 hover:bg-white/30" class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block"
} rounded-lg inline-block transition-all shadow-lg mx-auto cursor-pointer border-none appearance-none`} >
> Your grade: {grade}
{copied ? "Copied! 📋" : "📤 Share"} </p>
</button> <button
</div> onclick={handleShare}
{/if} data-umami-event="Share"
class={`mt-4 text-2xl font-bold p-2 ${
copied
? "bg-green-400/50 hover:bg-green-500/60"
: "bg-white/20 hover:bg-white/30"
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
>
{copied ? "shared!" : "📤 Share"}
</button>
</div>
{/if}
<!-- Guesses Grid --> <!-- Guesses Grid -->
<div class="bg-white rounded-2xl shadow-xl overflow-x-auto"> <div class="bg-white rounded-2xl shadow-xl overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="bg-linear-to-r from-gray-50 to-gray-100"> <tr class="bg-linear-to-r from-gray-50 to-gray-300">
<th <th
class="p-3 sm:p-4 md:p-6 text-left font-bold text-sm sm:text-base md:text-lg text-gray-700 border-b border-gray-200" 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"
>Book</th >Book</th
> >
<th <th
class="p-3 sm:p-4 md:p-6 text-left font-bold text-sm sm:text-base md:text-lg text-gray-700 border-b border-gray-200" 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"
>Testament</th >Testament</th
> >
<th <th
class="p-3 sm:p-4 md:p-6 text-left font-bold text-sm sm:text-base md:text-lg text-gray-700 border-b border-gray-200" 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 >Section</th
> >
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each guesses as guess (guess.book.id)} {#each guesses as guess (guess.book.id)}
<tr <tr
class="border-b border-gray-100 hover:bg-gray-50 transition-colors" class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
> >
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg"> <td
<!-- {guess.book.id === correctBookId ? "✅" : "❌"} --> class="p-3 sm:p-4 md:p-6 text-sm sm:text-base font-bold md:text-lg"
{guess.book.name} >
</td> {guess.book.id === correctBookId ? "✅" : "❌"}
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg"> {guess.book.name}
{guess.testamentMatch ? "✅" : "🟥"} </td>
{guess.book.testament.toUpperCase()} <td
</td> class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg"
<td >
class="p-3 sm:p-4 md:p-6 font-semibold text-sm sm:text-base md:text-lg" {guess.testamentMatch ? "✅" : "🟥"}
> {guess.book.testament.charAt(0).toUpperCase() +
{guess.sectionMatch ? "✅" : "🟥"} guess.book.testament.slice(1).toLowerCase()}
{guess.adjacent ? "‼️ " : ""}{guess.book.section} </td>
</td> <td
</tr> class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg"
{/each} >
</tbody> {guess.sectionMatch ? "✅" : "🟥"}
</table> {guess.adjacent ? "‼️ " : ""}{guess.book
</div> .section}
</div> </td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if isWon}
<div
class="mt-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>
{/if}
</div>
</div> </div>

View File

@@ -2,4 +2,9 @@ import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] }); export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
allowedHosts: ['bibdle.orthodox.cafe']
}
});