mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-02-04 10:54:44 -05:00
Version 1
This commit is contained in:
18
src/app.d.ts
vendored
Normal file
18
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: import('$lib/server/auth').SessionValidationResult['user'];
|
||||
session: import('$lib/server/auth').SessionValidationResult['session']
|
||||
}
|
||||
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
src/app.html
Normal file
11
src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
28
src/hooks.server.ts
Normal file
28
src/hooks.server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import * as auth from '$lib/server/auth';
|
||||
|
||||
const handleAuth: Handle = async ({ event, resolve }) => {
|
||||
const sessionToken = event.cookies.get(auth.sessionCookieName);
|
||||
|
||||
if (!sessionToken) {
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const { session, user } = await auth.validateSessionToken(sessionToken);
|
||||
|
||||
if (session) {
|
||||
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
||||
} else {
|
||||
auth.deleteSessionTokenCookie(event);
|
||||
}
|
||||
|
||||
event.locals.user = user;
|
||||
event.locals.session = session;
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handle: Handle = handleAuth;
|
||||
BIN
src/lib/assets/favicon.ico
Normal file
BIN
src/lib/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
81
src/lib/server/auth.ts
Normal file
81
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
|
||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export const sessionCookieName = 'auth-session';
|
||||
|
||||
export function generateSessionToken() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||
const token = encodeBase64url(bytes);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function createSession(token: string, userId: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const session: table.Session = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
|
||||
};
|
||||
await db.insert(table.session).values(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function validateSessionToken(token: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const [result] = await db
|
||||
.select({
|
||||
// Adjust user table here to tweak returned data
|
||||
user: { id: table.user.id, username: table.user.username },
|
||||
session: table.session
|
||||
})
|
||||
.from(table.session)
|
||||
.innerJoin(table.user, eq(table.session.userId, table.user.id))
|
||||
.where(eq(table.session.id, sessionId));
|
||||
|
||||
if (!result) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
const { session, user } = result;
|
||||
|
||||
const sessionExpired = Date.now() >= session.expiresAt.getTime();
|
||||
if (sessionExpired) {
|
||||
await db.delete(table.session).where(eq(table.session.id, session.id));
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
|
||||
if (renewSession) {
|
||||
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
|
||||
await db
|
||||
.update(table.session)
|
||||
.set({ expiresAt: session.expiresAt })
|
||||
.where(eq(table.session.id, session.id));
|
||||
}
|
||||
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
|
||||
|
||||
export async function invalidateSession(sessionId: string) {
|
||||
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||
}
|
||||
|
||||
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
||||
event.cookies.set(sessionCookieName, token, {
|
||||
expires: expiresAt,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
event.cookies.delete(sessionCookieName, {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
48
src/lib/server/bible-api.ts
Normal file
48
src/lib/server/bible-api.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
import { getBookById } from './bible';
|
||||
|
||||
type ApiVerse = Omit<DailyVerse, 'id' | 'date' | 'createdAt'>;
|
||||
|
||||
export async function fetchRandomVerse(): Promise<ApiVerse> {
|
||||
// Step 1: Fetch random verse to get starting position
|
||||
const randomRes = await fetch('https://bible-api.com/data/web/random');
|
||||
if (!randomRes.ok) {
|
||||
throw new Error(`Failed to fetch random verse: ${randomRes.status}`);
|
||||
}
|
||||
const randomData = await randomRes.json() as any;
|
||||
const randomVerse = randomData.random_verse;
|
||||
if (!randomVerse || !randomVerse.book_id) {
|
||||
throw new Error('Invalid random verse data');
|
||||
}
|
||||
|
||||
const bookId = randomVerse.book_id as string;
|
||||
const chapter = randomVerse.chapter as number;
|
||||
const verse = randomVerse.verse as number;
|
||||
const endVerse = verse + 2;
|
||||
|
||||
// Step 2: Fetch 3 consecutive verses starting from random
|
||||
const rangeUrl = `https://bible-api.com/${bookId}${chapter}:${verse}-${endVerse}`;
|
||||
const rangeRes = await fetch(rangeUrl);
|
||||
if (!rangeRes.ok) {
|
||||
throw new Error(`Failed to fetch verse range: ${rangeRes.status}`);
|
||||
}
|
||||
const rangeData = await rangeRes.json() as any;
|
||||
|
||||
const reference = rangeData.reference as string;
|
||||
const verses = rangeData.verses as any[];
|
||||
if (!verses || verses.length === 0) {
|
||||
throw new Error('No verses in range');
|
||||
}
|
||||
const verseText = verses.map((v: any) => v.text).join(' ');
|
||||
|
||||
// Validate bookId
|
||||
if (!getBookById(bookId)) {
|
||||
throw new Error(`Unknown book ID from API: ${bookId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
bookId,
|
||||
reference,
|
||||
verseText
|
||||
};
|
||||
}
|
||||
23
src/lib/server/bible.ts
Normal file
23
src/lib/server/bible.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { BibleBook, Testament, BibleSection } from '$lib/types/bible';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
|
||||
export function getBookById(id: string): BibleBook | undefined {
|
||||
return bibleBooks.find((book) => book.id === id);
|
||||
}
|
||||
|
||||
export function getBooksByTestament(testament: Testament): BibleBook[] {
|
||||
return bibleBooks.filter((book) => book.testament === testament);
|
||||
}
|
||||
|
||||
export function getBooksBySection(section: BibleSection): BibleBook[] {
|
||||
return bibleBooks.filter((book) => book.section === section);
|
||||
}
|
||||
|
||||
export function isAdjacent(bookId1: string, bookId2: string): boolean {
|
||||
const book1 = getBookById(bookId1);
|
||||
const book2 = getBookById(bookId2);
|
||||
if (!book1 || !book2) return false;
|
||||
return Math.abs(book1.order - book2.order) === 1;
|
||||
}
|
||||
|
||||
export { bibleBooks };
|
||||
10
src/lib/server/db/index.ts
Normal file
10
src/lib/server/db/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = new Database(env.DATABASE_URL);
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
26
src/lib/server/db/schema.ts
Normal file
26
src/lib/server/db/schema.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
||||
|
||||
export const session = sqliteTable('session', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => user.id),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
export type Session = typeof session.$inferSelect;
|
||||
|
||||
export type User = typeof user.$inferSelect;
|
||||
|
||||
export const dailyVerses = sqliteTable('daily_verses', {
|
||||
id: text('id').primaryKey(),
|
||||
date: text('date').unique().notNull(),
|
||||
bookId: text('book_id').notNull(),
|
||||
verseText: text('verse_text').notNull(),
|
||||
reference: text('reference').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }),
|
||||
});
|
||||
|
||||
export type DailyVerse = typeof dailyVerses.$inferSelect;
|
||||
91
src/lib/types/bible.ts
Normal file
91
src/lib/types/bible.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type Testament = 'old' | 'new';
|
||||
|
||||
export type BibleSection =
|
||||
| 'Law'
|
||||
| 'History'
|
||||
| 'Wisdom'
|
||||
| 'Major Prophets'
|
||||
| 'Minor Prophets'
|
||||
| 'Gospels'
|
||||
| 'Pauline Epistles'
|
||||
| 'General Epistles'
|
||||
| 'Apocalyptic';
|
||||
|
||||
export interface BibleBook {
|
||||
id: string;
|
||||
name: string;
|
||||
testament: Testament;
|
||||
section: BibleSection;
|
||||
order: number;
|
||||
url: string;
|
||||
popularity: number;
|
||||
}
|
||||
|
||||
export const bibleBooks: BibleBook[] = [
|
||||
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1, url: 'https://bible-api.com/data/web/GEN', popularity: 10 },
|
||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 2 },
|
||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
|
||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
|
||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
|
||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
|
||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
|
||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
|
||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 2 },
|
||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 2 },
|
||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 2 },
|
||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 2 },
|
||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 2 },
|
||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 2 },
|
||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 2 },
|
||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 2 },
|
||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 2 },
|
||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
|
||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 8 },
|
||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 8 },
|
||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
|
||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
|
||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
|
||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
|
||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
|
||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
|
||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
|
||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
|
||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
|
||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
|
||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
|
||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
|
||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
|
||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
|
||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
|
||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
|
||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
|
||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
|
||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
|
||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
|
||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
|
||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
|
||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
|
||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
|
||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
|
||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 6 },
|
||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 6 },
|
||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 6 },
|
||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 6 },
|
||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 6 },
|
||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 6 },
|
||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 6 },
|
||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 6 },
|
||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 6 },
|
||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 6 },
|
||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 6 },
|
||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 6 },
|
||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 6 },
|
||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 6 },
|
||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 6 },
|
||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 6 },
|
||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 6 },
|
||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 6 },
|
||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 6 },
|
||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 6 },
|
||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
|
||||
];
|
||||
16
src/routes/+layout.svelte
Normal file
16
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<script
|
||||
defer
|
||||
src="https://umami.snail.city/script.js"
|
||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
||||
></script>
|
||||
</svelte:head>
|
||||
{@render children()}
|
||||
45
src/routes/+page.server.ts
Normal file
45
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
import type { BibleBook } from '$lib/types/bible';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
async function getTodayVerse(): Promise<DailyVerse> {
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
const dateStr = today.toISOString().slice(0, 10);
|
||||
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: crypto.randomUUID(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const dailyVerse = await getTodayVerse();
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
return {
|
||||
dailyVerse,
|
||||
correctBookId: dailyVerse.bookId,
|
||||
correctBook
|
||||
};
|
||||
};
|
||||
316
src/routes/+page.svelte
Normal file
316
src/routes/+page.svelte
Normal file
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
||||
|
||||
interface Guess {
|
||||
book: BibleBook;
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
}
|
||||
|
||||
import type { PageProps } from "./$types";
|
||||
import { browser } from "$app/environment";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let correctBookId = $derived(data.correctBookId);
|
||||
|
||||
let guesses = $state<Guess[]>([]);
|
||||
|
||||
let searchQuery = $state("");
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
let filteredBooks = $derived(
|
||||
bibleBooks.filter((book) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
||||
let grade = $derived(
|
||||
isWon
|
||||
? getGrade(guesses.length, getBookById(correctBookId)?.popularity ?? 0)
|
||||
: ""
|
||||
);
|
||||
|
||||
function getBookById(id: string): BibleBook | undefined {
|
||||
return bibleBooks.find((b) => b.id === id);
|
||||
}
|
||||
|
||||
function isAdjacent(id1: string, id2: string): boolean {
|
||||
const b1 = getBookById(id1);
|
||||
const b2 = getBookById(id2);
|
||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||
}
|
||||
|
||||
function submitGuess(bookId: string) {
|
||||
if (guesses.some((g) => g.book.id === bookId)) return;
|
||||
|
||||
const book = getBookById(bookId);
|
||||
if (!book) return;
|
||||
|
||||
const correctBook = getBookById(correctBookId);
|
||||
if (!correctBook) return;
|
||||
|
||||
const testamentMatch = book.testament === correctBook.testament;
|
||||
const sectionMatch = book.section === correctBook.section;
|
||||
const adjacent = isAdjacent(book.id, correctBookId);
|
||||
|
||||
console.log(
|
||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`
|
||||
);
|
||||
|
||||
guesses = [
|
||||
{
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
},
|
||||
...guesses,
|
||||
];
|
||||
|
||||
searchQuery = "";
|
||||
}
|
||||
|
||||
function getGrade(numGuesses: number, popularity: number): string {
|
||||
const difficulty = 14 - popularity;
|
||||
const performanceScore = Math.max(0, 10 - numGuesses);
|
||||
const totalScore = performanceScore + difficulty * 0.8;
|
||||
if (totalScore >= 14) return "🟢 S";
|
||||
if (totalScore >= 11) return "🟢 A";
|
||||
if (totalScore >= 8) return "🟡 B";
|
||||
if (totalScore >= 5) return "🟠 C";
|
||||
return "🔴 C-";
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const key = `bibdle-guesses-${dailyVerse.date}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
let savedIds: string[] = JSON.parse(saved);
|
||||
savedIds = Array.from(new Set(savedIds));
|
||||
guesses = savedIds.map((bookId: string) => {
|
||||
const book = getBookById(bookId)!;
|
||||
const correctBook = getBookById(correctBookId)!;
|
||||
const testamentMatch = book.testament === correctBook.testament;
|
||||
const sectionMatch = book.section === correctBook.section;
|
||||
const adjacent = isAdjacent(bookId, correctBookId);
|
||||
return {
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(
|
||||
`bibdle-guesses-${dailyVerse.date}`,
|
||||
JSON.stringify(guesses.map((g) => g.book.id))
|
||||
);
|
||||
});
|
||||
|
||||
async function share() {
|
||||
if (!browser) return;
|
||||
|
||||
const emojis = guesses
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((guess) => {
|
||||
if (guess.book.id === correctBookId) return "✅";
|
||||
if (guess.adjacent) return "‼️";
|
||||
if (guess.sectionMatch) return "🟩";
|
||||
if (guess.testamentMatch) return "🟧";
|
||||
return "🟥";
|
||||
})
|
||||
.join("");
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const formattedDate = dateFormatter.format(
|
||||
new Date(`${dailyVerse.date}T00:00:00`)
|
||||
);
|
||||
const siteUrl = window.location.origin;
|
||||
const shareText = [
|
||||
`📖 Bibdle | ${formattedDate} 📖`,
|
||||
`${grade} (${guesses.length} guesses)`,
|
||||
emojis,
|
||||
siteUrl,
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareText);
|
||||
} catch (err) {
|
||||
console.error("Share failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
if (copied || !browser) return;
|
||||
copied = true;
|
||||
share()
|
||||
.then(() => {
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 5000);
|
||||
})
|
||||
.catch(() => {
|
||||
copied = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Bibdle</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100 py-8">
|
||||
<div
|
||||
class="max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl mx-auto px-2 sm:px-4"
|
||||
>
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center text-gray-800 mb-8 sm:mb-12 drop-shadow-lg"
|
||||
>
|
||||
Bibdle
|
||||
</h1>
|
||||
|
||||
<!-- Verse Display -->
|
||||
<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"
|
||||
>
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl leading-relaxed text-gray-700 italic text-center"
|
||||
>
|
||||
“{dailyVerse.verseText}”
|
||||
</blockquote>
|
||||
{#if !isWon}
|
||||
<!-- <p class="text-center text-sm text-gray-500 mt-4 font-medium">
|
||||
Guess the book!
|
||||
</p> -->
|
||||
{:else}
|
||||
<p class="text-center text-lg text-green-600 font-bold mt-4">
|
||||
{dailyVerse.reference}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isWon}
|
||||
<!-- Book Search -->
|
||||
<div class="mb-12">
|
||||
<input
|
||||
bind:value={searchQuery}
|
||||
placeholder="Type to search books (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"
|
||||
/>
|
||||
{#if searchQuery && filteredBooks.length > 0}
|
||||
<ul
|
||||
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>
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<span class="font-semibold">{book.name}</span>
|
||||
<span class="ml-auto text-sm opacity-75"
|
||||
>({book.testament.toUpperCase()})</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
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"
|
||||
in:fade={{ delay: 500 }}
|
||||
>
|
||||
<h2 class="text-4xl font-black mb-4 drop-shadow-lg">
|
||||
🎉 Congratulations! 🎉
|
||||
</h2>
|
||||
<p class="text-lg sm:text-xl md:text-2xl mb-8">
|
||||
The verse is from <span
|
||||
class="font-black text-xl sm:text-2xl md:text-3xl"
|
||||
>{getBookById(correctBookId)?.name}</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-xl opacity-90">{dailyVerse.reference}</p>
|
||||
<p
|
||||
class="text-2xl font-bold mt-6 p-2 bg-black/20 rounded-lg inline-block"
|
||||
>
|
||||
Your grade: {grade}
|
||||
</p>
|
||||
<button
|
||||
onclick={handleShare}
|
||||
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-auto cursor-pointer border-none appearance-none`}
|
||||
>
|
||||
{copied ? "Copied! 📋" : "📤 Share"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Guesses Grid -->
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-linear-to-r from-gray-50 to-gray-100">
|
||||
<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"
|
||||
>Book</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"
|
||||
>Testament</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"
|
||||
>Section</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each guesses as guess (guess.book.id)}
|
||||
<tr
|
||||
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">
|
||||
<!-- {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.toUpperCase()}
|
||||
</td>
|
||||
<td
|
||||
class="p-3 sm:p-4 md:p-6 font-semibold text-sm sm:text-base md:text-lg"
|
||||
>
|
||||
{guess.sectionMatch ? "✅" : "🟥"}
|
||||
{guess.adjacent ? "‼️ " : ""}{guess.book.section}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
2
src/routes/layout.css
Normal file
2
src/routes/layout.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
Reference in New Issue
Block a user