Added verse-submission infrastructure

This commit is contained in:
George Powell
2026-07-07 15:41:10 -04:00
parent 99552c57ad
commit be0d7ad297
17 changed files with 1293 additions and 26 deletions
+270
View File
@@ -0,0 +1,270 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import AuthModal from '$lib/components/AuthModal.svelte';
import Container from '$lib/components/Container.svelte';
import { bibleBooks } from '$lib/types/bible';
type ScheduledVerseRow = {
scheduledDate: string;
reference: string | null;
verseText: string | null;
bookId: string | null;
bookName: string | null;
selectedBookId: string;
selectedChapter: number;
selectedVerse: number;
submittedAt: number;
submitterEmail: string | null;
submitterName: string | null;
submitterDeleted: boolean;
};
interface PageData {
rows: ScheduledVerseRow[];
requiresAuth: boolean;
authorized: boolean;
user?: any;
session?: any;
}
let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let anonymousId = $state('');
let filter = $state<'all' | 'upcoming' | 'past'>('all');
const filters = ['all', 'upcoming', 'past'] as const;
function getOrCreateAnonymousId(): string {
if (!browser) return '';
const key = 'bibdle-anonymous-id';
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
onMount(() => {
anonymousId = getOrCreateAnonymousId();
});
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}
function formatTimestamp(ms: number): string {
return new Date(ms).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
});
}
const todayUtc = $derived(new Date().toISOString().slice(0, 10));
const filteredRows = $derived.by(() => {
if (filter === 'all') return data.rows;
if (filter === 'upcoming') return data.rows.filter((r) => r.scheduledDate >= todayUtc);
return data.rows.filter((r) => r.scheduledDate < todayUtc);
});
function selectedBookName(bookId: string): string {
return bibleBooks.find((b) => b.id === bookId)?.name ?? bookId;
}
</script>
<svelte:head>
<title>Scheduled Verses | Bibdle</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
>
<div class="max-w-5xl mx-auto">
<div class="text-center mb-6 md:mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-4">
Scheduled Verses
</h1>
<a href="/" class="p-2 px-20 w-full items-center text-gray-300">
&larr; Back to Game
</a>
</div>
{#if data.requiresAuth}
<div class="text-center py-12">
<div
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
>
<h2 class="text-2xl font-bold text-blue-200 mb-4">
Authentication Required
</h2>
<p class="text-blue-300 mb-6">
You must be signed in to view this page.
</p>
<div class="flex flex-col gap-3">
<button
onclick={() => (authModalOpen = true)}
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Sign In
</button>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
&larr; Back to Game
</a>
</div>
</div>
</div>
{:else if !data.authorized}
<div class="text-center py-12">
<div
class="bg-red-950/50 border border-red-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
>
<h2 class="text-2xl font-bold text-red-200 mb-4">Not Authorized</h2>
<p class="text-red-300 mb-6">You do not have access to this page.</p>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
&larr; Back to Game
</a>
</div>
</div>
{:else}
<!-- Filter toggle -->
<div class="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div class="flex gap-1 bg-white/5 rounded-lg p-1 border border-white/10">
{#each filters as f}
<button
onclick={() => (filter = f)}
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors {filter ===
f
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-white/5'}"
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
{/each}
</div>
<span class="text-xs text-gray-500">
{filteredRows.length} {filteredRows.length === 1 ? 'row' : 'rows'}
</span>
</div>
{#if filteredRows.length === 0}
<Container class="p-8 w-full text-center">
<p class="text-gray-400">No submissions yet.</p>
</Container>
{:else}
<Container class="p-2 md:p-4 w-full overflow-x-auto">
<table class="w-full text-sm text-left text-gray-200">
<thead
class="text-xs uppercase text-gray-400 border-b border-white/10"
>
<tr>
<th class="px-2 md:px-3 py-2">Scheduled</th>
<th class="px-2 md:px-3 py-2">Reference</th>
<th class="px-2 md:px-3 py-2 hidden md:table-cell">
Window text
</th>
<th class="px-2 md:px-3 py-2">Submitted by</th>
<th class="px-2 md:px-3 py-2 hidden sm:table-cell">
Submitted at
</th>
<th class="px-2 md:px-3 py-2 hidden lg:table-cell">
Selected
</th>
</tr>
</thead>
<tbody>
{#each filteredRows as row (row.scheduledDate)}
<tr
class="border-b border-white/5 hover:bg-white/5 align-top"
>
<td class="px-2 md:px-3 py-2 whitespace-nowrap">
<div class="font-medium text-gray-100">
{formatDate(row.scheduledDate)}
</div>
{#if row.scheduledDate < todayUtc}
<span class="text-[10px] text-gray-500"
>played</span
>
{:else if row.scheduledDate === todayUtc}
<span
class="text-[10px] text-emerald-400"
>today</span
>
{/if}
</td>
<td class="px-2 md:px-3 py-2">
{#if row.reference}
<div class="font-medium">
{row.reference}
</div>
<div class="text-[10px] text-gray-500">
{row.bookName ?? row.bookId}
</div>
{:else}
<span class="text-gray-500"
>— no daily_verse row —</span
>
{/if}
</td>
<td
class="px-2 md:px-3 py-2 hidden md:table-cell max-w-xs"
>
<span class="text-xs text-gray-400 line-clamp-4"
>{row.verseText ?? ''}</span
>
</td>
<td class="px-2 md:px-3 py-2">
{#if row.submitterDeleted}
<span class="text-gray-500 italic"
>(deleted user)</span
>
{:else}
{#if row.submitterName}
<div>{row.submitterName}</div>
{/if}
<div class="text-[11px] text-gray-400">
{row.submitterEmail ?? '—'}
</div>
{/if}
</td>
<td
class="px-2 md:px-3 py-2 hidden sm:table-cell whitespace-nowrap text-xs text-gray-400"
>
{formatTimestamp(row.submittedAt)}
</td>
<td
class="px-2 md:px-3 py-2 hidden lg:table-cell whitespace-nowrap text-xs text-gray-400"
>
{selectedBookName(row.selectedBookId)}
{row.selectedChapter}:{row.selectedVerse}
</td>
</tr>
{/each}
</tbody>
</table>
</Container>
{/if}
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />