Files
Critters/create-critter.php
T
pauljd 9805d5056d
pauljd/davidson-ci: Davidson CI / quality (push) Successful in 14s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 1m5s
issues identified by Davidson CI
2026-09-09 22:49:46 +01:00

538 lines
26 KiB
PHP

<?php
require __DIR__ . '/session.php';
require __DIR__ . '/db.php';
require __DIR__ . '/lib/ticket.php';
require __DIR__ . '/lib/mailer.php';
start_app_session();
if (!isset($_SESSION['username']) || !isset($_SESSION['user_id'])) {
header('Location: /');
exit;
}
$isLoggedIn = isset($_SESSION['username']) && isset($_SESSION['user_id']);
const DAILY_CRITTER_LIMIT = 30;
const CRITTER_COOLDOWN_SECONDS = 15;
const VERIFICATION_RESEND_COOLDOWN_SECONDS = 60;
$pdo = getDbConnection();
$userStmt = $pdo->prepare('SELECT email, username, credits, email_verified_at FROM users WHERE id = :id');
$userStmt->execute(['id' => $_SESSION['user_id']]);
$userRow = $userStmt->fetch(PDO::FETCH_ASSOC);
$userCredits = (int) ($userRow['credits'] ?? 0);
$isEmailVerified = !empty($userRow['email_verified_at']);
$verificationEmailSent = false;
$verificationResendError = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'resend_verification') {
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
$verificationResendError = 'Your session has expired. Please refresh the page and try again.';
} elseif ($isEmailVerified) {
$verificationResendError = 'Your email is already verified.';
} elseif (
isset($_SESSION['last_verification_resend_at'])
&& (time() - $_SESSION['last_verification_resend_at']) < VERIFICATION_RESEND_COOLDOWN_SECONDS
) {
$verificationResendError = 'Please wait a minute before requesting another verification email.';
} else {
$verifyToken = createTicket((int) $_SESSION['user_id'], 'email_verify', EMAIL_VERIFICATION_LIFETIME_SECONDS);
$appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/');
$verifyLink = $appUrl . '/verify-email.php?token=' . urlencode($verifyToken);
sendEmailVerificationEmail($userRow['email'], $userRow['username'], $verifyLink);
$_SESSION['last_verification_resend_at'] = time();
$verificationEmailSent = true;
}
}
$pdo->exec('CREATE TABLE IF NOT EXISTS critters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
noun_id INTEGER NOT NULL REFERENCES nouns(id),
adjective_id INTEGER NOT NULL REFERENCES adjectives(id),
verb_id INTEGER NOT NULL REFERENCES verbs(id),
location_id INTEGER NOT NULL REFERENCES locations(id),
author_id INTEGER NOT NULL REFERENCES users(id),
image TEXT,
isFavorite INTEGER NOT NULL DEFAULT 0,
isArchived INTEGER NOT NULL DEFAULT 0,
datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
$critterColumns = $pdo->query('PRAGMA table_info(critters)')->fetchAll(PDO::FETCH_COLUMN, 1);
if (!in_array('author_id', $critterColumns, true)) {
$pdo->exec('ALTER TABLE critters ADD COLUMN author_id INTEGER REFERENCES users(id)');
}
if (!in_array('image', $critterColumns, true)) {
$pdo->exec('ALTER TABLE critters ADD COLUMN image TEXT');
}
if (!in_array('isFavorite', $critterColumns, true)) {
$pdo->exec('ALTER TABLE critters ADD COLUMN isFavorite INTEGER NOT NULL DEFAULT 0');
}
if (!in_array('isArchived', $critterColumns, true)) {
$pdo->exec('ALTER TABLE critters ADD COLUMN isArchived INTEGER NOT NULL DEFAULT 0');
}
if (!in_array('datetime', $critterColumns, true)) {
$pdo->exec('ALTER TABLE critters ADD COLUMN datetime TEXT');
$pdo->exec("UPDATE critters SET datetime = CURRENT_TIMESTAMP WHERE datetime IS NULL");
}
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_noun_id ON critters (noun_id)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_adjective_id ON critters (adjective_id)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_verb_id ON critters (verb_id)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_location_id ON critters (location_id)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_author_id ON critters (author_id)');
function countCrittersCreatedTodayByAuthor(PDO $pdo, int $authorId): int
{
$stmt = $pdo->prepare("SELECT COUNT(*) FROM critters WHERE author_id = :author_id AND date(datetime) = date('now')");
$stmt->execute(['author_id' => $authorId]);
return (int) $stmt->fetchColumn();
}
function secondsSinceLastCritterByAuthor(PDO $pdo, int $authorId): ?int
{
$stmt = $pdo->prepare("SELECT CAST(strftime('%s', 'now') - strftime('%s', datetime) AS INTEGER) FROM critters WHERE author_id = :author_id ORDER BY datetime DESC LIMIT 1");
$stmt->execute(['author_id' => $authorId]);
$seconds = $stmt->fetchColumn();
return $seconds === false ? null : (int) $seconds;
}
$dailyLimitReached = countCrittersCreatedTodayByAuthor($pdo, (int) $_SESSION['user_id']) >= DAILY_CRITTER_LIMIT;
$cooldownRemaining = 0;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$secondsSinceLast = secondsSinceLastCritterByAuthor($pdo, (int) $_SESSION['user_id']);
if ($secondsSinceLast !== null && $secondsSinceLast < CRITTER_COOLDOWN_SECONDS) {
$cooldownRemaining = CRITTER_COOLDOWN_SECONDS - $secondsSinceLast;
}
}
function isAllowedReplicateUrl(string $url): bool
{
$parts = parse_url($url);
if ($parts === false || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) {
return false;
}
$host = strtolower($parts['host']);
return $host === 'api.replicate.com'
|| $host === 'replicate.delivery'
|| substr($host, -strlen('.replicate.delivery')) === '.replicate.delivery';
}
function getValueById(PDO $pdo, string $table, $id): string
{
$stmt = $pdo->prepare("SELECT value FROM {$table} WHERE id = :id");
$stmt->execute(['id' => $id]);
return (string) $stmt->fetchColumn();
}
$adjectives = $pdo->query('SELECT id, value FROM adjectives ORDER BY value')->fetchAll(PDO::FETCH_ASSOC);
$nouns = $pdo->query('SELECT id, value FROM nouns ORDER BY value')->fetchAll(PDO::FETCH_ASSOC);
$verbs = $pdo->query('SELECT id, value FROM verbs ORDER BY value')->fetchAll(PDO::FETCH_ASSOC);
$locations = $pdo->query('SELECT id, value FROM locations ORDER BY value')->fetchAll(PDO::FETCH_ASSOC);
$fields = [
'adjective_id' => ['table' => 'adjectives', 'label' => 'adjective'],
'noun_id' => ['table' => 'nouns', 'label' => 'noun'],
'verb_id' => ['table' => 'verbs', 'label' => 'verb'],
'location_id' => ['table' => 'locations', 'label' => 'location'],
];
$errors = [];
$selected = [
'adjective_id' => '',
'noun_id' => '',
'verb_id' => '',
'location_id' => '',
];
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
foreach ($fields as $field => $info) {
$value = $_GET[$field] ?? '';
if ($value !== '' && ctype_digit((string) $value) && rowExistsById($pdo, $info['table'], $value)) {
$selected[$field] = (int) $value;
}
}
$hasRemixParams = $selected['adjective_id'] !== ''
|| $selected['noun_id'] !== ''
|| $selected['verb_id'] !== ''
|| $selected['location_id'] !== '';
if (!$hasRemixParams) {
$optionLists = [
'adjective_id' => $adjectives,
'noun_id' => $nouns,
'verb_id' => $verbs,
'location_id' => $locations,
];
foreach ($optionLists as $field => $rows) {
if (!empty($rows)) {
$selected[$field] = (int) $rows[array_rand($rows)]['id'];
}
}
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $userCredits > 0 && !$dailyLimitReached) {
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
}
foreach ($fields as $field => $info) {
$value = $_POST[$field] ?? '';
$selected[$field] = $value;
if ($value === '' || !ctype_digit((string) $value) || !rowExistsById($pdo, $info['table'], $value)) {
$errors[$field] = 'Please choose a valid ' . $info['label'] . '.';
}
}
if (empty($errors) && countCrittersCreatedTodayByAuthor($pdo, (int) $_SESSION['user_id']) >= DAILY_CRITTER_LIMIT) {
$dailyLimitReached = true;
}
if (empty($errors) && $cooldownRemaining > 0) {
$errors['cooldown'] = "Please wait {$cooldownRemaining} more second(s) before creating another critter.";
}
$creditReserved = false;
if (empty($errors) && !$dailyLimitReached) {
// Atomically reserve one credit in the same statement that checks for it, so
// concurrent requests can't all read "1 credit" before any of them writes the
// deduction. SQLite serializes writes, so only as many of these UPDATEs can
// match "credits > 0" as there are credits actually available; everyone else
// gets 0 rows affected and is turned away before the expensive Replicate call.
$reserveStmt = $pdo->prepare('UPDATE users SET credits = credits - 1 WHERE id = :id AND credits > 0');
$reserveStmt->execute(['id' => $_SESSION['user_id']]);
$creditReserved = $reserveStmt->rowCount() === 1;
if (!$creditReserved) {
$userCredits = 0;
$errors['credits'] = 'You do not have enough credits to create a critter.';
}
}
if (empty($errors) && !$dailyLimitReached && $creditReserved) {
$adjectiveValue = getValueById($pdo, 'adjectives', $selected['adjective_id']);
$nounValue = getValueById($pdo, 'nouns', $selected['noun_id']);
$verbValue = getValueById($pdo, 'verbs', $selected['verb_id']);
$locationValue = getValueById($pdo, 'locations', $selected['location_id']);
$prompt = "A {$adjectiveValue} {$nounValue} {$verbValue} {$locationValue} in a high quality 3d animation art style. The image should be vibrant and colorful, capturing the essence of the action and setting.";
$ch = curl_init('https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_CAINFO, '/etc/ssl/certs/ca-certificates.crt');
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . getenv('REPLICATE_API_TOKEN'),
'Content-Type: application/json',
'Prefer: wait',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'input' => [
'prompt' => $prompt,
],
]));
$replicateResponse = curl_exec($ch);
$imageUrl = null;
if ($replicateResponse !== false) {
$prediction = json_decode($replicateResponse, true);
$pollAttempt = 0;
while (
isset($prediction['status'])
&& !in_array($prediction['status'], ['succeeded', 'failed', 'canceled'], true)
&& $pollAttempt < 30
) {
$pollUrl = $prediction['urls']['get'] ?? '';
if (!is_string($pollUrl) || !isAllowedReplicateUrl($pollUrl)) {
$prediction['status'] = 'failed';
break;
}
sleep(2);
$pollCh = curl_init($pollUrl);
curl_setopt($pollCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($pollCh, CURLOPT_CAINFO, '/etc/ssl/certs/ca-certificates.crt');
curl_setopt($pollCh, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
curl_setopt($pollCh, CURLOPT_TIMEOUT, 15);
curl_setopt($pollCh, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . getenv('REPLICATE_API_TOKEN'),
]);
$pollResponse = curl_exec($pollCh);
if ($pollResponse !== false) {
$prediction = json_decode($pollResponse, true);
}
$pollAttempt++;
}
if (($prediction['status'] ?? null) === 'succeeded') {
$output = $prediction['output'] ?? null;
$imageUrl = is_array($output) ? ($output[0] ?? null) : $output;
}
}
$imageFilename = null;
if (is_string($imageUrl) && isAllowedReplicateUrl($imageUrl)) {
$imageCh = curl_init($imageUrl);
curl_setopt($imageCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($imageCh, CURLOPT_CAINFO, '/etc/ssl/certs/ca-certificates.crt');
curl_setopt($imageCh, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
curl_setopt($imageCh, CURLOPT_TIMEOUT, 30);
curl_setopt($imageCh, CURLOPT_MAXFILESIZE, 15 * 1024 * 1024);
$imageData = curl_exec($imageCh);
if ($imageData !== false && strlen($imageData) > 0 && strlen($imageData) <= 15 * 1024 * 1024) {
$imageInfo = @getimagesizefromstring($imageData);
$allowedImageTypes = ['image/webp', 'image/png', 'image/jpeg'];
if ($imageInfo !== false && in_array($imageInfo['mime'] ?? '', $allowedImageTypes, true)) {
$imagesDir = __DIR__ . '/critter-images';
if (!is_dir($imagesDir)) {
mkdir($imagesDir, 0755, true);
}
$imageFilename = bin2hex(random_bytes(16)) . '.webp';
file_put_contents($imagesDir . '/' . $imageFilename, $imageData);
}
}
}
if ($imageFilename === null) {
// Every unsuccessful path above (request failure, failed/canceled prediction,
// disallowed or missing image URL, oversized or non-image download) leaves
// $imageFilename null, so this single check refunds the reserved credit for
// all of them instead of only the initial request-failure case.
$refundStmt = $pdo->prepare('UPDATE users SET credits = credits + 1 WHERE id = :id');
$refundStmt->execute(['id' => $_SESSION['user_id']]);
}
$stmt = $pdo->prepare('INSERT INTO critters (noun_id, adjective_id, verb_id, location_id, author_id, image, datetime) VALUES (:noun_id, :adjective_id, :verb_id, :location_id, :author_id, :image, :datetime)');
$stmt->execute([
'noun_id' => $selected['noun_id'],
'adjective_id' => $selected['adjective_id'],
'verb_id' => $selected['verb_id'],
'location_id' => $selected['location_id'],
'author_id' => $_SESSION['user_id'],
'image' => $imageFilename,
'datetime' => gmdate('Y-m-d H:i:s'),
]);
header('Location: /');
exit;
}
}
function renderOptions(array $rows, $selectedId): void
{
foreach ($rows as $row) {
$isSelected = (string) $selectedId === (string) $row['id'];
echo '<option value="' . (int) $row['id'] . '"' . ($isSelected ? ' selected' : '') . '>'
. htmlspecialchars($row['value'], ENT_QUOTES, 'UTF-8')
. '</option>';
}
}
$pageTitle = 'Create Critter';
?>
<!DOCTYPE html>
<html lang="en">
<?php require __DIR__ . '/lib/head.php'; ?>
<body class="bg-amber-50 min-h-screen flex flex-col">
<?php require __DIR__ . '/lib/header.php'; ?>
<main class="flex-1">
<?php if (!$isEmailVerified): ?>
<div class="max-w-md mx-auto px-6 py-20 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-purple-50 text-purple-300 mx-auto mb-4">
<i data-lucide="mail-check" class="h-8 w-8"></i>
</div>
<h1 class="text-2xl font-extrabold text-gray-900 mb-2">Verify your email to start creating</h1>
<p class="text-gray-600">We sent a verification link to your email when you signed up. Verify it to unlock your <?php echo STARTING_CREDITS; ?> free credits.</p>
<?php if ($verificationEmailSent): ?>
<p class="mt-4 text-sm text-green-600">Verification email sent! Check your inbox.</p>
<?php elseif ($verificationResendError): ?>
<p class="mt-4 text-sm text-red-600"><?php echo htmlspecialchars($verificationResendError, ENT_QUOTES, 'UTF-8'); ?></p>
<?php endif; ?>
<form method="post" class="mt-6">
<?php echo csrf_field(); ?>
<input type="hidden" name="action" value="resend_verification">
<button type="submit" class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 text-white font-semibold px-5 py-2.5 shadow-sm hover:from-purple-700 hover:to-pink-600">
<i data-lucide="send" class="h-4 w-4"></i> Resend verification email
</button>
</form>
</div>
<?php elseif ($dailyLimitReached): ?>
<div class="max-w-md mx-auto px-6 py-20 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-purple-50 text-purple-300 mx-auto mb-4">
<i data-lucide="calendar-clock" class="h-8 w-8"></i>
</div>
<h1 class="text-2xl font-extrabold text-gray-900 mb-2">Critter limit reached for the day</h1>
<p class="text-gray-600">Critter limit reached for the day, please come back tomorrow.</p>
</div>
<?php elseif ($userCredits <= 0): ?>
<div class="max-w-md mx-auto px-6 py-20 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-purple-50 text-purple-300 mx-auto mb-4">
<i data-lucide="coins" class="h-8 w-8"></i>
</div>
<h1 class="text-2xl font-extrabold text-gray-900 mb-2">Out of credits</h1>
<p class="text-gray-600">You have 0 credits remaining, so you can't create a new critter right now.</p>
<a href="#" onClick="openCheckout(itemsList)" class="mt-6 inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 text-white font-semibold px-5 py-2.5 shadow-sm hover:from-purple-700 hover:to-pink-600">
<i data-lucide="shopping-cart" class="h-4 w-4"></i> Buy Credits
</a>
</div>
<?php else: ?>
<div class="max-w-xl mx-auto px-6 py-12">
<div class="text-center mb-8">
<p class="text-sm font-semibold tracking-widest text-purple-600 uppercase mb-2">Imagine &middot; Mix &middot; Create</p>
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-900">Create Your New Critter</h1>
<p class="mt-2 text-gray-500">Pick a recipe and we'll bring it to life.</p>
</div>
<div class="rounded-3xl bg-white ring-1 ring-gray-100 shadow-sm p-6 sm:p-8">
<?php if (isset($errors['credits']) || isset($errors['cooldown']) || isset($errors['csrf'])): ?>
<div class="mb-5 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
<?php echo htmlspecialchars($errors['credits'] ?? $errors['cooldown'] ?? $errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php endif; ?>
<form id="createCritterForm" action="create-critter.php" method="post" novalidate class="space-y-5">
<?php echo csrf_field(); ?>
<div>
<label for="adjective_id" class="block text-sm font-medium text-gray-700 mb-1">Adjective</label>
<div class="relative">
<i data-lucide="wand" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-pink-500"></i>
<select id="adjective_id" name="adjective_id" class="w-full appearance-none rounded-xl border border-gray-300 pl-10 pr-9 py-2.5 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-400 transition-shadow">
<option value="">-- Select an adjective --</option>
<?php renderOptions($adjectives, $selected['adjective_id']); ?>
</select>
<i data-lucide="chevron-down" class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
</div>
<?php if (isset($errors['adjective_id'])): ?>
<div class="text-sm text-red-600 mt-1"><?php echo htmlspecialchars($errors['adjective_id'], ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
</div>
<div>
<label for="noun_id" class="block text-sm font-medium text-gray-700 mb-1">Animal</label>
<div class="relative">
<i data-lucide="paw-print" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-500"></i>
<select id="noun_id" name="noun_id" class="w-full appearance-none rounded-xl border border-gray-300 pl-10 pr-9 py-2.5 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-400 transition-shadow">
<option value="">-- Select a noun --</option>
<?php renderOptions($nouns, $selected['noun_id']); ?>
</select>
<i data-lucide="chevron-down" class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
</div>
<?php if (isset($errors['noun_id'])): ?>
<div class="text-sm text-red-600 mt-1"><?php echo htmlspecialchars($errors['noun_id'], ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
</div>
<div>
<label for="verb_id" class="block text-sm font-medium text-gray-700 mb-1">Action</label>
<div class="relative">
<i data-lucide="play" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500"></i>
<select id="verb_id" name="verb_id" class="w-full appearance-none rounded-xl border border-gray-300 pl-10 pr-9 py-2.5 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-400 transition-shadow">
<option value="">-- Select a verb --</option>
<?php renderOptions($verbs, $selected['verb_id']); ?>
</select>
<i data-lucide="chevron-down" class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
</div>
<?php if (isset($errors['verb_id'])): ?>
<div class="text-sm text-red-600 mt-1"><?php echo htmlspecialchars($errors['verb_id'], ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
</div>
<div>
<label for="location_id" class="block text-sm font-medium text-gray-700 mb-1">Location</label>
<div class="relative">
<i data-lucide="map-pin" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-purple-500"></i>
<select id="location_id" name="location_id" class="w-full appearance-none rounded-xl border border-gray-300 pl-10 pr-9 py-2.5 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-400 transition-shadow">
<option value="">-- Select a location --</option>
<?php renderOptions($locations, $selected['location_id']); ?>
</select>
<i data-lucide="chevron-down" class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
</div>
<?php if (isset($errors['location_id'])): ?>
<div class="text-sm text-red-600 mt-1"><?php echo htmlspecialchars($errors['location_id'], ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
</div>
<button type="submit" id="createCritterSubmit" class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 text-white font-semibold py-3 shadow-sm hover:from-purple-700 hover:to-pink-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-400 focus-visible:ring-offset-2">
<i data-lucide="sparkles" class="h-4 w-4"></i> Create Critter
</button>
</form>
</div>
</div>
<div id="creatingOverlay" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-purple-600 via-pink-500 to-amber-500 p-6">
<div class="w-full max-w-sm rounded-3xl bg-white shadow-2xl p-8 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-pink-50 text-pink-500 mb-5" style="animation: critter-bounce 1.1s ease-in-out infinite;">
<i data-lucide="paw-print" class="h-8 w-8"></i>
</div>
<h2 class="text-xl font-extrabold text-gray-900">Creating your critter&hellip;</h2>
<p id="creatingMessage" class="mt-2 text-gray-500 min-h-[1.5em]">Mixing colors&hellip;</p>
<div class="mt-6 h-2 w-full rounded-full bg-gray-100 overflow-hidden">
<div class="h-full w-1/3 rounded-full bg-gradient-to-r from-purple-600 via-pink-500 to-amber-500" style="animation: critter-progress 1.4s ease-in-out infinite;"></div>
</div>
<p class="mt-5 text-xs text-gray-400">This can take up to a minute &mdash; hang tight!</p>
</div>
</div>
<style>
@keyframes critter-bounce {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-10px) scale(1.08); }
}
@keyframes critter-progress {
0% { transform: translateX(-110%); }
50% { transform: translateX(60%); }
100% { transform: translateX(310%); }
}
</style>
<script>
(function () {
const form = document.getElementById('createCritterForm');
if (!form) return;
const messages = [
'Mixing colors…',
'Sketching whiskers…',
'Adding a sprinkle of magic…',
'Choosing the perfect pose…',
'Bringing your critter to life…',
'Almost there…',
];
form.addEventListener('submit', function () {
const overlay = document.getElementById('creatingOverlay');
const messageEl = document.getElementById('creatingMessage');
const submitBtn = document.getElementById('createCritterSubmit');
overlay.classList.remove('hidden');
if (window.lucide) lucide.createIcons();
if (submitBtn) submitBtn.disabled = true;
let index = 0;
setInterval(function () {
index = (index + 1) % messages.length;
messageEl.textContent = messages[index];
}, 2200);
});
})();
</script>
<?php endif; ?>
</main>
<?php require __DIR__ . '/lib/footer.php'; ?>
</body>
</html>