mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
996 lines
25 KiB
Svelte
996 lines
25 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { fly } from "svelte/transition";
|
|
import { bibleBooks } from "$lib/types/bible";
|
|
|
|
let {
|
|
isLoggedIn = false,
|
|
anonymousId = "",
|
|
localDate = "",
|
|
}: {
|
|
isLoggedIn?: boolean;
|
|
anonymousId?: string;
|
|
localDate?: string;
|
|
} = $props();
|
|
|
|
// ── Status (logged-in state) ───────────────────────────────────────────
|
|
type Status = {
|
|
canSubmit: boolean;
|
|
cooldownEndsAt: number | null;
|
|
lastSubmission: { scheduledDate: string; reference: string } | null;
|
|
upcoming: { scheduledDate: string; reference: string }[];
|
|
};
|
|
let status = $state<Status | null>(null);
|
|
let statusError = $state<string | null>(null);
|
|
|
|
// ── Bible structure (Book → chapters → verse counts) ──────────────────
|
|
type Structure = { bookId: string; chapters: number[] }[];
|
|
let structure = $state<Structure | null>(null);
|
|
|
|
// ── Selector state ─────────────────────────────────────────────────────
|
|
let selectedBookId = $state<string>("");
|
|
let selectedChapter = $state<number>(0);
|
|
let selectedVerse = $state<number>(0);
|
|
|
|
// ── Preview state ──────────────────────────────────────────────────────
|
|
type Preview = {
|
|
windowVerses: string[];
|
|
reference: string;
|
|
bookId: string;
|
|
selectedVerse: number;
|
|
};
|
|
let preview = $state<Preview | null>(null);
|
|
let previewLoading = $state(false);
|
|
|
|
// ── Submission state ───────────────────────────────────────────────────
|
|
let submitting = $state(false);
|
|
let submitError = $state<string | null>(null);
|
|
let submitResult = $state<{ scheduledDate: string; reference: string } | null>(
|
|
null,
|
|
);
|
|
|
|
// ── UI state ───────────────────────────────────────────────────────────
|
|
let expanded = $state(false);
|
|
let countdownText = $state("");
|
|
let countdownId: number | null = null;
|
|
|
|
// Book list grouped by Testament for the <select> optgroups.
|
|
const oldBooks = $derived(bibleBooks.filter((b) => b.testament === "old"));
|
|
const newBooks = $derived(bibleBooks.filter((b) => b.testament === "new"));
|
|
|
|
// Chapter list for the currently selected book (1..N).
|
|
const chapterCount = $derived(
|
|
structure && selectedBookId
|
|
? structure.find((b) => b.bookId === selectedBookId)?.chapters.length ??
|
|
0
|
|
: 0,
|
|
);
|
|
// Verse count for the currently selected chapter.
|
|
const verseCount = $derived(
|
|
structure && selectedBookId && selectedChapter
|
|
? structure.find((b) => b.bookId === selectedBookId)?.chapters[
|
|
selectedChapter - 1
|
|
] ?? 0
|
|
: 0,
|
|
);
|
|
|
|
// Whether the user is currently on cooldown (only meaningful when logged in).
|
|
const onCooldown = $derived(
|
|
!!status && !status.canSubmit && status.cooldownEndsAt !== null,
|
|
);
|
|
|
|
// Debounce handle for the live preview fetch.
|
|
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function trackEvent(name: string) {
|
|
try {
|
|
(window as any).rybbit?.event?.(name);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
|
|
function fmtDateLong(dateStr: string): string {
|
|
// dateStr is YYYY-MM-DD; format as "August 24, 2026" in UTC to avoid drift.
|
|
const d = new Date(dateStr + "T00:00:00Z");
|
|
return d.toLocaleDateString("en-US", {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
timeZone: "UTC",
|
|
});
|
|
}
|
|
|
|
function fmtDateVague(dateStr: string): string {
|
|
// dateStr is YYYY-MM-DD. Return a deliberately vague timeframe instead of
|
|
// revealing the exact scheduled date.
|
|
const scheduled = new Date(dateStr + "T00:00:00Z");
|
|
const now = new Date();
|
|
const today = new Date(
|
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
|
);
|
|
const diffDays = Math.round(
|
|
(scheduled.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
|
);
|
|
if (diffDays <= 14) return "a few days from now";
|
|
if (diffDays <= 60) return "a few weeks from now";
|
|
return "within the next few months";
|
|
}
|
|
|
|
async function loadStatus() {
|
|
if (!isLoggedIn || !localDate) return;
|
|
try {
|
|
const res = await fetch(
|
|
`/api/submit-verse/status?localDate=${encodeURIComponent(localDate)}`,
|
|
);
|
|
if (res.status === 401) {
|
|
status = null;
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
statusError = "Couldn't load submission status.";
|
|
return;
|
|
}
|
|
status = await res.json();
|
|
} catch {
|
|
statusError = "Couldn't load submission status.";
|
|
}
|
|
}
|
|
|
|
async function loadStructure() {
|
|
if (structure) return;
|
|
try {
|
|
const res = await fetch("/api/bible/structure");
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
structure = data;
|
|
// Default selection: first book, chapter 1, verse 1.
|
|
if (data.length > 0 && !selectedBookId) {
|
|
selectedBookId = data[0].bookId;
|
|
selectedChapter = 1;
|
|
selectedVerse = 1;
|
|
}
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
|
|
function fetchPreview() {
|
|
if (previewTimer) clearTimeout(previewTimer);
|
|
previewTimer = setTimeout(async () => {
|
|
if (!selectedBookId || !selectedChapter || !selectedVerse) {
|
|
preview = null;
|
|
return;
|
|
}
|
|
previewLoading = true;
|
|
try {
|
|
const res = await fetch(
|
|
`/api/verse-window?bookId=${encodeURIComponent(
|
|
selectedBookId,
|
|
)}&chapter=${selectedChapter}&verse=${selectedVerse}`,
|
|
);
|
|
if (res.ok) {
|
|
preview = await res.json();
|
|
}
|
|
} catch {
|
|
/* noop */
|
|
} finally {
|
|
previewLoading = false;
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function onBookChange() {
|
|
selectedChapter = 1;
|
|
selectedVerse = 1;
|
|
submitError = null;
|
|
fetchPreview();
|
|
}
|
|
|
|
function onChapterChange() {
|
|
selectedVerse = 1;
|
|
submitError = null;
|
|
fetchPreview();
|
|
}
|
|
|
|
function onVerseChange() {
|
|
submitError = null;
|
|
fetchPreview();
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
if (!isLoggedIn) return;
|
|
submitting = true;
|
|
submitError = null;
|
|
try {
|
|
const res = await fetch("/api/submit-verse", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
bookId: selectedBookId,
|
|
chapter: selectedChapter,
|
|
verse: selectedVerse,
|
|
localDate,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
if (res.status === 429 && data?.cooldownEndsAt) {
|
|
// Refresh status so the cooldown UI kicks in.
|
|
await loadStatus();
|
|
submitError = "You're on cooldown — try again later.";
|
|
} else {
|
|
submitError = data?.error ?? "Submission failed.";
|
|
}
|
|
return;
|
|
}
|
|
submitResult = {
|
|
scheduledDate: data.scheduledDate,
|
|
reference: data.reference,
|
|
};
|
|
trackEvent("Submit a verse");
|
|
// Refresh status so the cooldown reflects the new submission.
|
|
await loadStatus();
|
|
} catch {
|
|
submitError = "Network error — please try again.";
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
|
|
function updateCountdown() {
|
|
if (!status?.cooldownEndsAt) {
|
|
countdownText = "";
|
|
return;
|
|
}
|
|
const diff = status.cooldownEndsAt - Date.now();
|
|
if (diff <= 0) {
|
|
countdownText = "";
|
|
// Cooldown elapsed — refresh status once.
|
|
loadStatus();
|
|
if (countdownId) {
|
|
clearInterval(countdownId);
|
|
countdownId = null;
|
|
}
|
|
return;
|
|
}
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
countdownText =
|
|
(days > 0 ? `${days}d ` : "") +
|
|
`${hours.toString().padStart(2, "0")}h ${minutes
|
|
.toString()
|
|
.padStart(2, "0")}m`;
|
|
}
|
|
|
|
onMount(() => {
|
|
if (isLoggedIn) {
|
|
loadStatus();
|
|
loadStructure();
|
|
}
|
|
});
|
|
|
|
// Start/stop the countdown timer as cooldown state changes.
|
|
$effect(() => {
|
|
if (onCooldown && status?.cooldownEndsAt && !countdownId) {
|
|
updateCountdown();
|
|
countdownId = window.setInterval(updateCountdown, 1000);
|
|
} else if (!onCooldown && countdownId) {
|
|
clearInterval(countdownId);
|
|
countdownId = null;
|
|
countdownText = "";
|
|
}
|
|
});
|
|
|
|
// (Re)load status + structure when the user logs in.
|
|
$effect(() => {
|
|
if (isLoggedIn) {
|
|
loadStatus();
|
|
loadStructure();
|
|
} else {
|
|
status = null;
|
|
structure = null;
|
|
expanded = false;
|
|
submitResult = null;
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (countdownId) clearInterval(countdownId);
|
|
if (previewTimer) clearTimeout(previewTimer);
|
|
});
|
|
</script>
|
|
|
|
<div class="signin-prompt submit-verse-wrap">
|
|
{#if submitResult}
|
|
<!-- ── Confirmation state (replaces the panel after success) ──────── -->
|
|
<div class="confirm-card" in:fly={{ y: -8, duration: 220 }}>
|
|
<p class="confirm-title">
|
|
✅ Your verse is scheduled for
|
|
<strong>{fmtDateVague(submitResult.scheduledDate)}</strong>
|
|
</p>
|
|
<p class="confirm-ref">{submitResult.reference}</p>
|
|
<button
|
|
type="button"
|
|
class="submit-another-btn"
|
|
onclick={() => {
|
|
submitResult = null;
|
|
expanded = false;
|
|
}}
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
{:else if !isLoggedIn}
|
|
<!-- ── State 1: logged out — sign-in dropdown ─────────────────────── -->
|
|
<button
|
|
type="button"
|
|
class="progress-btn w-full"
|
|
aria-expanded={expanded}
|
|
onclick={() => (expanded = !expanded)}
|
|
data-umami-event="Submit a verse (logged out)"
|
|
>
|
|
<span>✨ Submit a verse</span>
|
|
<svg
|
|
class="progress-chev"
|
|
class:open={expanded}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</button>
|
|
{#if expanded}
|
|
<p class="signin-text text-gray-800 dark:text-gray-300">
|
|
Sign in to submit a verse for a future day
|
|
</p>
|
|
<form method="POST" action="/auth/apple" class="w-full">
|
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
|
<button
|
|
type="submit"
|
|
class="apple-signin-btn"
|
|
data-umami-event="Sign in with Apple"
|
|
>
|
|
<svg class="apple-icon" 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.09zM12.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>
|
|
</form>
|
|
<form method="POST" action="/auth/google" class="w-full">
|
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
|
<button
|
|
type="submit"
|
|
class="google-signin-btn"
|
|
data-umami-event="Sign in with Google"
|
|
>
|
|
<svg
|
|
class="google-icon"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<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.93l3.66-2.84z"
|
|
/>
|
|
<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>
|
|
</form>
|
|
{/if}
|
|
{:else if onCooldown}
|
|
<!-- ── State 2: logged in, cooldown active — disabled + countdown on button ─── -->
|
|
<button
|
|
type="button"
|
|
class="progress-btn w-full progress-btn-disabled"
|
|
disabled
|
|
aria-disabled="true"
|
|
>
|
|
<!-- <span>✨ Submit a verse</span> -->
|
|
{#if countdownText}
|
|
<span class="countdown-inline" aria-hidden="true"
|
|
>⏳ {countdownText}</span
|
|
>
|
|
{:else}
|
|
<span class="lock-icon" aria-hidden="true">⏳</span>
|
|
{/if}
|
|
</button>
|
|
{:else}
|
|
<!-- ── State 3: logged in, can submit — selector + preview ────────── -->
|
|
<button
|
|
type="button"
|
|
class="progress-btn w-full"
|
|
aria-expanded={expanded}
|
|
onclick={() => (expanded = !expanded)}
|
|
data-umami-event="Submit a verse (logged in)"
|
|
>
|
|
<span>✨ Submit a verse</span>
|
|
<svg
|
|
class="progress-chev"
|
|
class:open={expanded}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</button>
|
|
{#if expanded}
|
|
<div class="submit-panel" in:fly={{ y: -8, duration: 220 }}>
|
|
{#if statusError}
|
|
<p class="panel-error">{statusError}</p>
|
|
{/if}
|
|
|
|
{#if !structure}
|
|
<p class="panel-hint">Loading Bible structure…</p>
|
|
{:else}
|
|
<div class="select-row">
|
|
<label class="select-label">
|
|
<span class="select-cap">Book</span>
|
|
<select
|
|
class="cascading-select"
|
|
value={selectedBookId}
|
|
onchange={(e) => {
|
|
selectedBookId = e.currentTarget.value;
|
|
onBookChange();
|
|
}}
|
|
>
|
|
<optgroup label="Old Testament">
|
|
{#each oldBooks as book (book.id)}
|
|
<option value={book.id}>{book.name}</option>
|
|
{/each}
|
|
</optgroup>
|
|
<optgroup label="New Testament">
|
|
{#each newBooks as book (book.id)}
|
|
<option value={book.id}>{book.name}</option>
|
|
{/each}
|
|
</optgroup>
|
|
</select>
|
|
</label>
|
|
|
|
<label class="select-label">
|
|
<span class="select-cap">Chapter</span>
|
|
<select
|
|
class="cascading-select"
|
|
value={selectedChapter}
|
|
onchange={(e) => {
|
|
selectedChapter = Number(e.currentTarget.value);
|
|
onChapterChange();
|
|
}}
|
|
>
|
|
{#if chapterCount === 0}
|
|
<option value={0}>—</option>
|
|
{:else}
|
|
{#each Array(chapterCount) as _, i (i)}
|
|
<option value={i + 1}>{i + 1}</option>
|
|
{/each}
|
|
{/if}
|
|
</select>
|
|
</label>
|
|
|
|
<label class="select-label">
|
|
<span class="select-cap">Verse</span>
|
|
<select
|
|
class="cascading-select"
|
|
value={selectedVerse}
|
|
onchange={(e) => {
|
|
selectedVerse = Number(e.currentTarget.value);
|
|
onVerseChange();
|
|
}}
|
|
>
|
|
{#if verseCount === 0}
|
|
<option value={0}>—</option>
|
|
{:else}
|
|
{#each Array(verseCount) as _, i (i)}
|
|
<option value={i + 1}>{i + 1}</option>
|
|
{/each}
|
|
{/if}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Live 3-verse preview -->
|
|
<div class="preview-card">
|
|
{#if previewLoading}
|
|
<p class="preview-hint">Loading preview…</p>
|
|
{:else if preview}
|
|
<p class="preview-ref">{preview.reference}</p>
|
|
<div class="preview-verses">
|
|
{#each preview.windowVerses as verseText, i (i)}
|
|
<p class="preview-verse">{verseText}</p>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="preview-hint">Select a verse to preview.</p>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if submitError}
|
|
<p class="panel-error">{submitError}</p>
|
|
{/if}
|
|
|
|
<button
|
|
type="button"
|
|
class="submit-btn"
|
|
disabled={submitting ||
|
|
!selectedBookId ||
|
|
!selectedChapter ||
|
|
!selectedVerse ||
|
|
!preview}
|
|
onclick={handleSubmit}
|
|
>
|
|
{submitting ? "Scheduling…" : "Submit verse"}
|
|
</button>
|
|
{#if status?.upcoming && status.upcoming.length > 0}
|
|
<p class="upcoming-text">
|
|
Your upcoming verse{status.upcoming.length > 1
|
|
? "s"
|
|
: ""}:
|
|
{#each status.upcoming as u, i (i)}
|
|
{#if i > 0}<span class="upcoming-sep">·</span>{/if}
|
|
<span class="upcoming-item"
|
|
>{u.reference} ({fmtDateVague(u.scheduledDate)})</span
|
|
>
|
|
{/each}
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.submit-verse-wrap {
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
/* Reuse WinScreen's neobrutalist .progress-btn base (scoped here too). */
|
|
:global(.submit-verse-wrap .progress-btn) {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
padding: 0.85rem 1.25rem;
|
|
background: #fff;
|
|
color: #111;
|
|
border: 2px solid #000;
|
|
border-radius: 0.75rem;
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
box-shadow: 6px 6px 0 0 #000;
|
|
transition:
|
|
transform 80ms ease,
|
|
box-shadow 80ms ease;
|
|
}
|
|
:global(.submit-verse-wrap .progress-btn:hover) {
|
|
transform: translate(-2px, -2px);
|
|
box-shadow: 8px 8px 0 0 #000;
|
|
}
|
|
:global(.submit-verse-wrap .progress-btn:active) {
|
|
transform: translate(2px, 2px);
|
|
box-shadow: 2px 2px 0 0 #000;
|
|
}
|
|
:global(.submit-verse-wrap .progress-chev) {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
transition: transform 200ms ease;
|
|
}
|
|
:global(.submit-verse-wrap .progress-chev.open) {
|
|
transform: rotate(180deg);
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:global(.submit-verse-wrap .progress-btn) {
|
|
background: #111827;
|
|
color: #f9fafb;
|
|
border-color: #fff;
|
|
box-shadow: 6px 6px 0 0 #fff;
|
|
}
|
|
:global(.submit-verse-wrap .progress-btn:hover) {
|
|
box-shadow: 8px 8px 0 0 #fff;
|
|
}
|
|
:global(.submit-verse-wrap .progress-btn:active) {
|
|
box-shadow: 2px 2px 0 0 #fff;
|
|
}
|
|
}
|
|
|
|
/* Disabled (cooldown) variant. */
|
|
:global(.submit-verse-wrap .progress-btn-disabled) {
|
|
opacity: 0.55;
|
|
cursor: not-allowed;
|
|
box-shadow: 4px 4px 0 0 #000;
|
|
}
|
|
:global(.submit-verse-wrap .progress-btn-disabled:hover),
|
|
:global(.submit-verse-wrap .progress-btn-disabled:active) {
|
|
transform: none;
|
|
box-shadow: 4px 4px 0 0 #000;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:global(.submit-verse-wrap .progress-btn-disabled) {
|
|
box-shadow: 4px 4px 0 0 #fff;
|
|
}
|
|
:global(.submit-verse-wrap .progress-btn-disabled:hover),
|
|
:global(.submit-verse-wrap .progress-btn-disabled:active) {
|
|
box-shadow: 4px 4px 0 0 #fff;
|
|
}
|
|
}
|
|
|
|
.lock-icon {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.countdown-inline {
|
|
font-size: 0.85rem;
|
|
font-weight: 700;
|
|
font-variant-numeric: tabular-nums;
|
|
letter-spacing: 0.01em;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.signin-text {
|
|
font-size: 0.85rem;
|
|
text-align: center;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* ── Apple / Google sign-in buttons (mirror WinScreen) ── */
|
|
:global(.submit-verse-wrap .apple-signin-btn),
|
|
:global(.submit-verse-wrap .google-signin-btn) {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
padding: 0.6rem 1rem;
|
|
width: 100%;
|
|
background: #000;
|
|
color: #fff;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
border: none;
|
|
cursor: pointer;
|
|
transition:
|
|
background 150ms ease,
|
|
transform 80ms ease;
|
|
}
|
|
:global(.submit-verse-wrap .apple-signin-btn) {
|
|
margin-bottom: 0.6rem;
|
|
}
|
|
:global(.submit-verse-wrap .apple-signin-btn:hover),
|
|
:global(.submit-verse-wrap .google-signin-btn:hover) {
|
|
background: #222;
|
|
transform: translateY(-1px);
|
|
}
|
|
:global(.submit-verse-wrap .apple-signin-btn:active),
|
|
:global(.submit-verse-wrap .google-signin-btn:active) {
|
|
background: #111;
|
|
transform: scale(0.98);
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:global(.submit-verse-wrap .apple-signin-btn),
|
|
:global(.submit-verse-wrap .google-signin-btn) {
|
|
background: #fff;
|
|
color: #000;
|
|
}
|
|
:global(.submit-verse-wrap .apple-signin-btn:hover),
|
|
:global(.submit-verse-wrap .google-signin-btn:hover) {
|
|
background: #e5e5e5;
|
|
}
|
|
:global(.submit-verse-wrap .apple-signin-btn:active),
|
|
:global(.submit-verse-wrap .google-signin-btn:active) {
|
|
background: #ccc;
|
|
}
|
|
}
|
|
:global(.submit-verse-wrap .apple-icon),
|
|
:global(.submit-verse-wrap .google-icon) {
|
|
width: 1.1rem;
|
|
height: 1.1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Submit panel ── */
|
|
.submit-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.85rem;
|
|
width: 100%;
|
|
padding: 1rem;
|
|
background: oklch(94% 0.028 298.626);
|
|
border: 2px solid #000;
|
|
border-radius: 0.75rem;
|
|
box-shadow: 6px 6px 0 0 #000;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.submit-panel {
|
|
background: oklch(22% 0.025 298.626);
|
|
border-color: #fff;
|
|
box-shadow: 6px 6px 0 0 #fff;
|
|
}
|
|
}
|
|
|
|
.panel-hint,
|
|
.preview-hint {
|
|
font-size: 0.85rem;
|
|
color: #6b7280;
|
|
text-align: center;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.panel-hint,
|
|
.preview-hint {
|
|
color: #9ca3af;
|
|
}
|
|
}
|
|
|
|
.panel-error {
|
|
font-size: 0.85rem;
|
|
color: #b91c1c;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.panel-error {
|
|
color: #f87171;
|
|
}
|
|
}
|
|
|
|
.select-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.select-label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
flex: 1;
|
|
min-width: 5rem;
|
|
}
|
|
|
|
.select-cap {
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: #6b7280;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.select-cap {
|
|
color: #9ca3af;
|
|
}
|
|
}
|
|
|
|
.cascading-select {
|
|
width: 100%;
|
|
padding: 0.5rem 0.6rem;
|
|
border: 2px solid #000;
|
|
border-radius: 0.5rem;
|
|
background: #fff;
|
|
color: #111;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.cascading-select {
|
|
background: #111827;
|
|
color: #f9fafb;
|
|
border-color: #fff;
|
|
}
|
|
}
|
|
|
|
/* ── Preview card ── */
|
|
.preview-card {
|
|
padding: 0.75rem;
|
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
border-radius: 0.5rem;
|
|
background: rgba(255, 255, 255, 0.5);
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.preview-card {
|
|
border-color: rgba(255, 255, 255, 0.15);
|
|
background: rgba(0, 0, 0, 0.25);
|
|
}
|
|
}
|
|
|
|
.preview-ref {
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
color: #374151;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.preview-ref {
|
|
color: #d1d5db;
|
|
}
|
|
}
|
|
|
|
.preview-verses {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.preview-verse {
|
|
font-size: 0.82rem;
|
|
line-height: 1.45;
|
|
color: #4b5563;
|
|
font-style: italic;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.preview-verse {
|
|
color: #9ca3af;
|
|
}
|
|
}
|
|
|
|
/* ── Submit button (neobrutalist) ── */
|
|
.submit-btn {
|
|
padding: 0.7rem 1rem;
|
|
background: #16a34a;
|
|
color: #fff;
|
|
border: 2px solid #000;
|
|
border-radius: 0.6rem;
|
|
font-size: 0.95rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
box-shadow: 4px 4px 0 0 #000;
|
|
transition:
|
|
transform 80ms ease,
|
|
box-shadow 80ms ease,
|
|
background-color 120ms ease;
|
|
}
|
|
.submit-btn:hover:not(:disabled) {
|
|
transform: translate(-2px, -2px);
|
|
box-shadow: 6px 6px 0 0 #000;
|
|
background: #15803d;
|
|
}
|
|
.submit-btn:active:not(:disabled) {
|
|
transform: translate(2px, 2px);
|
|
box-shadow: 2px 2px 0 0 #000;
|
|
}
|
|
.submit-btn:disabled {
|
|
opacity: 0.55;
|
|
cursor: not-allowed;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.submit-btn {
|
|
border-color: #fff;
|
|
box-shadow: 4px 4px 0 0 #fff;
|
|
}
|
|
.submit-btn:hover:not(:disabled) {
|
|
box-shadow: 6px 6px 0 0 #fff;
|
|
}
|
|
.submit-btn:active:not(:disabled) {
|
|
box-shadow: 2px 2px 0 0 #fff;
|
|
}
|
|
}
|
|
|
|
/* ── Upcoming list ── */
|
|
.upcoming-text {
|
|
font-size: 0.72rem;
|
|
color: #6b7280;
|
|
text-align: center;
|
|
line-height: 1.5;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.upcoming-text {
|
|
color: #9ca3af;
|
|
}
|
|
}
|
|
.upcoming-item {
|
|
font-weight: 600;
|
|
}
|
|
.upcoming-sep {
|
|
margin: 0 0.25rem;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
/* ── Confirmation card ── */
|
|
.confirm-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
width: 100%;
|
|
padding: 1.1rem;
|
|
background: oklch(94% 0.028 298.626);
|
|
border: 2px solid #000;
|
|
border-radius: 0.75rem;
|
|
box-shadow: 6px 6px 0 0 #000;
|
|
text-align: center;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.confirm-card {
|
|
background: oklch(22% 0.025 298.626);
|
|
border-color: #fff;
|
|
box-shadow: 6px 6px 0 0 #fff;
|
|
}
|
|
}
|
|
|
|
.confirm-title {
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
color: #111;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.confirm-title {
|
|
color: #f9fafb;
|
|
}
|
|
}
|
|
|
|
.confirm-ref {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: #374151;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.confirm-ref {
|
|
color: #d1d5db;
|
|
}
|
|
}
|
|
|
|
.submit-another-btn {
|
|
padding: 0.5rem 1.1rem;
|
|
background: #fff;
|
|
color: #111;
|
|
border: 2px solid #000;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
box-shadow: 3px 3px 0 0 #000;
|
|
transition:
|
|
transform 80ms ease,
|
|
box-shadow 80ms ease;
|
|
}
|
|
.submit-another-btn:hover {
|
|
transform: translate(-1px, -1px);
|
|
box-shadow: 4px 4px 0 0 #000;
|
|
}
|
|
.submit-another-btn:active {
|
|
transform: translate(1px, 1px);
|
|
box-shadow: 2px 2px 0 0 #000;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
.submit-another-btn {
|
|
background: #111827;
|
|
color: #f9fafb;
|
|
border-color: #fff;
|
|
box-shadow: 3px 3px 0 0 #fff;
|
|
}
|
|
.submit-another-btn:hover {
|
|
box-shadow: 4px 4px 0 0 #fff;
|
|
}
|
|
.submit-another-btn:active {
|
|
box-shadow: 2px 2px 0 0 #fff;
|
|
}
|
|
}
|
|
</style>
|