mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-08-22 22:32:28 -04:00
330 lines
9.5 KiB
Svelte
330 lines
9.5 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
import { onMount } from 'svelte';
|
|
import { invalidateAll } from '$app/navigation';
|
|
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;
|
|
|
|
// Dev-only seed button (calls POST /api/dev/seed-submission, which is
|
|
// host-gated to localhost:5173 / test.bibdle.com). Hidden in prod.
|
|
const isDevHost = $derived(
|
|
browser &&
|
|
['localhost:5173', 'test.bibdle.com'].includes(window.location.host)
|
|
);
|
|
let seeding = $state(false);
|
|
let seedMessage = $state<{ ok: boolean; text: string } | null>(null);
|
|
|
|
async function seedSubmission() {
|
|
seeding = true;
|
|
seedMessage = null;
|
|
try {
|
|
const res = await fetch('/api/dev/seed-submission', { method: 'POST' });
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
seedMessage = {
|
|
ok: false,
|
|
text: body?.error ?? `Failed (${res.status})`
|
|
};
|
|
} else {
|
|
seedMessage = {
|
|
ok: true,
|
|
text: `Seeded ${body?.scheduledDate ?? ''} — ${body?.reference ?? ''}`
|
|
};
|
|
// Reload server load data so the new row appears in the table.
|
|
await invalidateAll();
|
|
}
|
|
} catch (err) {
|
|
seedMessage = { ok: false, text: String(err) };
|
|
} finally {
|
|
seeding = false;
|
|
}
|
|
}
|
|
|
|
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">
|
|
← 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"
|
|
>
|
|
← 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"
|
|
>
|
|
← 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 items-center gap-2 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>
|
|
|
|
{#if isDevHost}
|
|
<button
|
|
onclick={seedSubmission}
|
|
disabled={seeding}
|
|
class="px-3 py-1.5 rounded-md text-xs font-medium border border-white/10 bg-white/5 text-gray-200 hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{seeding ? 'Seeding…' : '+ Seed test submission'}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
{#if seedMessage}
|
|
<span
|
|
class="text-xs {seedMessage.ok
|
|
? 'text-emerald-400'
|
|
: 'text-red-400'}"
|
|
>
|
|
{seedMessage.text}
|
|
</span>
|
|
{/if}
|
|
<span class="text-xs text-gray-500">
|
|
{filteredRows.length} {filteredRows.length === 1 ? 'row' : 'rows'}
|
|
</span>
|
|
</div>
|
|
</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} />
|