mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-06-25 08:45:22 -04:00
feat: add progress page with activity calendar, book grid, and insights
Adds a new /progress route showing a personalized Bible knowledge dashboard with stat cards, book mastery grid, 30-day activity calendar, skill growth chart, streak milestones, and section insights. Links added from WinScreen (logged-in users) and DevButtons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,658 @@
|
||||
<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 ProgressStatCard from "$lib/components/ProgressStatCard.svelte";
|
||||
import ActivityCalendar from "$lib/components/ActivityCalendar.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
|
||||
type BookTier = "unseen" | "explored" | "mastered" | "perfect";
|
||||
|
||||
type BookGridEntry = {
|
||||
bookId: string;
|
||||
tier: BookTier;
|
||||
avgGuesses: number | null;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type ChartPoint = {
|
||||
label: string;
|
||||
avgGuesses: number;
|
||||
};
|
||||
|
||||
type SectionStat = {
|
||||
section: string;
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type ProgressData = {
|
||||
completions: Array<{ date: string; guessCount: number }>;
|
||||
chartPoints: ChartPoint[];
|
||||
bookGrid: BookGridEntry[];
|
||||
sectionStats: SectionStat[];
|
||||
testamentStats: {
|
||||
old: { avgGuesses: number; count: number } | null;
|
||||
new: { avgGuesses: number; count: number } | null;
|
||||
};
|
||||
totalSolves: number;
|
||||
bestStreak: number;
|
||||
currentStreak: number;
|
||||
booksExplored: number;
|
||||
booksMastered: number;
|
||||
booksPerfect: number;
|
||||
bestSingleGame: { date: string; bookName: string } | null;
|
||||
totalWords: number;
|
||||
streakMilestones: {
|
||||
days7: string | null;
|
||||
days14: string | null;
|
||||
days30: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
interface PageData {
|
||||
progress: ProgressData | null;
|
||||
error?: string;
|
||||
user?: any;
|
||||
session?: any;
|
||||
requiresAuth?: boolean;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let authModalOpen = $state(false);
|
||||
let anonymousId = $state("");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function bookTileClass(tier: BookTier): string {
|
||||
switch (tier) {
|
||||
case "perfect":
|
||||
return "bg-amber-400 text-amber-900";
|
||||
case "mastered":
|
||||
return "bg-emerald-600 text-white";
|
||||
case "explored":
|
||||
return "bg-blue-700 text-blue-100";
|
||||
default:
|
||||
return "bg-gray-700/50 text-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
});
|
||||
|
||||
// Derived SVG chart values
|
||||
const chartPoints = $derived(data.progress?.chartPoints ?? []);
|
||||
const showChart = $derived(chartPoints.length >= 3);
|
||||
const maxGuesses = $derived(
|
||||
showChart ? Math.max(...chartPoints.map((p) => p.avgGuesses)) : 6,
|
||||
);
|
||||
const chartImproving = $derived(
|
||||
showChart &&
|
||||
chartPoints[chartPoints.length - 1].avgGuesses <
|
||||
chartPoints[0].avgGuesses,
|
||||
);
|
||||
|
||||
function svgX(index: number, total: number): number {
|
||||
return (index / (total - 1)) * 360 + 20;
|
||||
}
|
||||
|
||||
function svgY(avgGuesses: number, maxG: number): number {
|
||||
return 100 - ((maxG - avgGuesses) / (maxG - 1)) * 90 + 10;
|
||||
}
|
||||
|
||||
const polylinePoints = $derived(
|
||||
showChart
|
||||
? chartPoints
|
||||
.map(
|
||||
(p, i) =>
|
||||
`${svgX(i, chartPoints.length)},${svgY(p.avgGuesses, maxGuesses)}`,
|
||||
)
|
||||
.join(" ")
|
||||
: "",
|
||||
);
|
||||
|
||||
// Insights helpers
|
||||
const progress = $derived(data.progress);
|
||||
const bestSection = $derived(
|
||||
progress?.sectionStats.find((s) => s.count >= 3) ?? null,
|
||||
);
|
||||
const hardestSection = $derived.by(() => {
|
||||
if (!progress) return null;
|
||||
const eligible = progress.sectionStats.filter((s) => s.count >= 3);
|
||||
if (eligible.length === 0) return null;
|
||||
const last = eligible[eligible.length - 1];
|
||||
if (bestSection && last.section === bestSection.section) return null;
|
||||
return last;
|
||||
});
|
||||
|
||||
const showInsights = $derived(
|
||||
progress !== null &&
|
||||
((progress.testamentStats.old !== null &&
|
||||
progress.testamentStats.new !== null) ||
|
||||
bestSection !== null),
|
||||
);
|
||||
|
||||
function testamentComparison(
|
||||
old_: { avgGuesses: number; count: number } | null,
|
||||
new_: { avgGuesses: number; count: number } | null,
|
||||
): string | null {
|
||||
if (!old_ || !new_) return null;
|
||||
const ratio = old_.avgGuesses / new_.avgGuesses;
|
||||
if (ratio < 0.85) {
|
||||
const x = (new_.avgGuesses / old_.avgGuesses).toFixed(1);
|
||||
return `You're ${x}x faster at Old Testament books`;
|
||||
}
|
||||
if (ratio > 1.18) {
|
||||
const x = (old_.avgGuesses / new_.avgGuesses).toFixed(1);
|
||||
return `You're ${x}x faster at New Testament books`;
|
||||
}
|
||||
return "Your speed is similar for Old and New Testament books";
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Your Progress | Bibdle</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Track your Bible knowledge journey with Bibdle"
|
||||
/>
|
||||
</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-3xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
||||
Your Progress
|
||||
</h1>
|
||||
<p class="text-sm md:text-base text-gray-300 mb-4">
|
||||
Your Bible knowledge journey
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
← 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 logged in to see your progress.
|
||||
</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 / Sign Up
|
||||
</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.error}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<p class="text-red-300">{data.error}</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Return to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !data.progress}
|
||||
<div class="text-center py-12">
|
||||
<Container class="p-8 max-w-md mx-auto">
|
||||
<div class="text-yellow-400 mb-4 text-lg">
|
||||
No progress yet.
|
||||
</div>
|
||||
<p class="text-gray-300 mb-6">
|
||||
Start playing to build your Bible knowledge journey!
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
|
||||
>
|
||||
Start Playing
|
||||
</a>
|
||||
</Container>
|
||||
</div>
|
||||
{:else}
|
||||
{@const prog = data.progress}
|
||||
|
||||
<!-- Key Stats Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mb-6">
|
||||
<ProgressStatCard
|
||||
emoji="📅"
|
||||
value={String(prog.totalSolves)}
|
||||
label="Total Played"
|
||||
colorClass="text-blue-400"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="📖"
|
||||
value={String(prog.booksExplored)}
|
||||
label="Books Explored"
|
||||
colorClass="text-teal-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="✍️"
|
||||
value={prog.totalWords.toLocaleString()}
|
||||
label="Words Read"
|
||||
colorClass="text-violet-400"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="✝️"
|
||||
value={(((prog.totalSolves * 3) / 31102) * 100).toFixed(2) +
|
||||
"%"}
|
||||
label="Bible Read"
|
||||
colorClass="text-amber-400"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="🏆"
|
||||
value={String(prog.booksMastered)}
|
||||
label="Books Mastered"
|
||||
colorClass="text-emerald-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="⭐"
|
||||
value={String(prog.booksPerfect)}
|
||||
label="Books Perfected"
|
||||
colorClass="text-amber-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bible Book Grid -->
|
||||
<div class="mb-6">
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<h2
|
||||
class="text-xl font-bold text-gray-100 mb-3 w-full text-left"
|
||||
>
|
||||
Bible Books
|
||||
</h2>
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<span
|
||||
class="flex items-center gap-1 text-xs text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-5 h-5 rounded bg-blue-700"
|
||||
></span>
|
||||
Explored
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-xs text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-5 h-5 rounded bg-emerald-600"
|
||||
></span>
|
||||
Mastered
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-xs text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-5 h-5 rounded bg-amber-400"
|
||||
></span>
|
||||
Perfect
|
||||
</span>
|
||||
</div>
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-8 md:grid-cols-11 gap-1 w-full">
|
||||
{#each prog.bookGrid as entry (entry.bookId)}
|
||||
{@const bookMeta = bibleBooks.find(
|
||||
(b) => b.id === entry.bookId,
|
||||
)}
|
||||
<div
|
||||
class="aspect-square flex items-center justify-center rounded text-[9px] md:text-[10px] font-bold cursor-default {bookTileClass(
|
||||
entry.tier,
|
||||
)}"
|
||||
title="{bookMeta?.name ??
|
||||
entry.bookId} — {entry.tier}{entry.avgGuesses !==
|
||||
null
|
||||
? ` (avg ${entry.avgGuesses})`
|
||||
: ''}"
|
||||
>
|
||||
{entry.bookId}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<!-- Activity Calendar -->
|
||||
<div class="mb-6">
|
||||
<ActivityCalendar completions={prog.completions} />
|
||||
</div>
|
||||
|
||||
<!-- Skill Growth Chart -->
|
||||
{#if showChart}
|
||||
<div class="mb-6">
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<div class="w-full">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<h2 class="text-xl font-bold text-gray-100">
|
||||
Skill Growth
|
||||
</h2>
|
||||
<span class="text-xs text-gray-400">
|
||||
Lower is better
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 400 120"
|
||||
class="w-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="chartFill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stop-color="#10b981"
|
||||
stop-opacity="0.3"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stop-color="#10b981"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Fill polygon -->
|
||||
<polygon
|
||||
points="{polylinePoints} {svgX(
|
||||
chartPoints.length - 1,
|
||||
chartPoints.length,
|
||||
)},110 {svgX(0, chartPoints.length)},110"
|
||||
fill="url(#chartFill)"
|
||||
/>
|
||||
<!-- Line -->
|
||||
<polyline
|
||||
points={polylinePoints}
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- Dots -->
|
||||
{#each chartPoints as point, i (i)}
|
||||
<circle
|
||||
cx={svgX(i, chartPoints.length)}
|
||||
cy={svgY(point.avgGuesses, maxGuesses)}
|
||||
r="3"
|
||||
fill="#10b981"
|
||||
/>
|
||||
{/each}
|
||||
<!-- X-axis labels -->
|
||||
<text
|
||||
x={svgX(0, chartPoints.length)}
|
||||
y="118"
|
||||
font-size="8"
|
||||
fill="#9ca3af"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{chartPoints[0].label}
|
||||
</text>
|
||||
<text
|
||||
x={svgX(
|
||||
chartPoints.length - 1,
|
||||
chartPoints.length,
|
||||
)}
|
||||
y="118"
|
||||
font-size="8"
|
||||
fill="#9ca3af"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{chartPoints[chartPoints.length - 1].label}
|
||||
</text>
|
||||
</svg>
|
||||
{#if chartImproving}
|
||||
<p class="text-xs text-emerald-400 mt-1">
|
||||
You're getting better!
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Milestones -->
|
||||
{#if prog.bestSingleGame || prog.streakMilestones.days7 || prog.streakMilestones.days14 || prog.streakMilestones.days30}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-3">
|
||||
Milestones
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{#if prog.bestSingleGame}
|
||||
<Container class="p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">⚡</div>
|
||||
<div
|
||||
class="text-sm font-bold text-yellow-300 leading-tight"
|
||||
>
|
||||
{prog.bestSingleGame.bookName}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-300 font-medium mt-1"
|
||||
>
|
||||
First 1-Guess Win
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(prog.bestSingleGame.date)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{#if prog.streakMilestones.days7}
|
||||
<Container class="p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">🔥</div>
|
||||
<div
|
||||
class="text-sm font-bold text-orange-300 leading-tight"
|
||||
>
|
||||
7-Day Streak
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-300 font-medium mt-1"
|
||||
>
|
||||
First Achieved
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(
|
||||
prog.streakMilestones.days7,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{#if prog.streakMilestones.days14}
|
||||
<Container class="p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">💥</div>
|
||||
<div
|
||||
class="text-sm font-bold text-orange-400 leading-tight"
|
||||
>
|
||||
14-Day Streak
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-300 font-medium mt-1"
|
||||
>
|
||||
First Achieved
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(
|
||||
prog.streakMilestones.days14,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{#if prog.streakMilestones.days30}
|
||||
<Container class="p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">🏅</div>
|
||||
<div
|
||||
class="text-sm font-bold text-amber-300 leading-tight"
|
||||
>
|
||||
30-Day Streak
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-300 font-medium mt-1"
|
||||
>
|
||||
First Achieved
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(
|
||||
prog.streakMilestones.days30,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Insights -->
|
||||
{#if showInsights}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-3">
|
||||
Insights
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if prog.testamentStats.old && prog.testamentStats.new}
|
||||
{@const comparison = testamentComparison(
|
||||
prog.testamentStats.old,
|
||||
prog.testamentStats.new,
|
||||
)}
|
||||
{#if comparison}
|
||||
<Container class="p-4 w-full">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="text-2xl">📊</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-gray-100 font-medium text-sm"
|
||||
>
|
||||
{comparison}
|
||||
</p>
|
||||
<p
|
||||
class="text-gray-400 text-xs mt-0.5"
|
||||
>
|
||||
OT avg: {prog.testamentStats.old
|
||||
.avgGuesses} guesses • NT
|
||||
avg: {prog.testamentStats.new
|
||||
.avgGuesses} guesses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if bestSection && bestSection.count >= 3}
|
||||
<Container class="p-4 w-full">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="text-2xl">🌟</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-gray-100 font-medium text-sm"
|
||||
>
|
||||
Your strongest section: {bestSection.section}
|
||||
</p>
|
||||
<p class="text-gray-400 text-xs mt-0.5">
|
||||
{bestSection.avgGuesses} avg guesses
|
||||
across {bestSection.count} games
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
{#if hardestSection}
|
||||
{@const hard = hardestSection}
|
||||
{#if hard && hard.count >= 3}
|
||||
<Container class="p-4 w-full">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="text-2xl">💪</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-gray-100 font-medium text-sm"
|
||||
>
|
||||
Room to grow: {hard.section}
|
||||
</p>
|
||||
<p
|
||||
class="text-gray-400 text-xs mt-0.5"
|
||||
>
|
||||
{hard.avgGuesses} avg guesses across
|
||||
{hard.count} games
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||
Reference in New Issue
Block a user