577 lines
29 KiB
PHP
577 lines
29 KiB
PHP
<?php
|
|
require __DIR__ . '/session.php';
|
|
require __DIR__ . '/db.php';
|
|
require __DIR__ . '/lib/ticket.php';
|
|
require __DIR__ . '/lib/mailer.php';
|
|
start_app_session();
|
|
|
|
$pdo = getDbConnection();
|
|
enforce_session_version($pdo);
|
|
|
|
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;
|
|
|
|
$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;
|
|
|
|
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'] . '.';
|
|
}
|
|
}
|
|
|
|
$creditReserved = false;
|
|
$critterId = null;
|
|
|
|
if (empty($errors)) {
|
|
// Hold SQLite's single write lock across the daily-limit check, the
|
|
// cooldown check, the credit reservation, and the placeholder insert
|
|
// so concurrent requests can't all read the same stale count/cooldown
|
|
// before any of them commits. The row is inserted (image NULL) here,
|
|
// before the slow Replicate call, so it immediately counts toward the
|
|
// daily limit and cooldown for any other request racing in.
|
|
$pdo->exec('BEGIN IMMEDIATE');
|
|
try {
|
|
if (countCrittersCreatedTodayByAuthor($pdo, (int) $_SESSION['user_id']) >= DAILY_CRITTER_LIMIT) {
|
|
$dailyLimitReached = true;
|
|
}
|
|
|
|
$cooldownRemaining = 0;
|
|
$secondsSinceLast = secondsSinceLastCritterByAuthor($pdo, (int) $_SESSION['user_id']);
|
|
if ($secondsSinceLast !== null && $secondsSinceLast < CRITTER_COOLDOWN_SECONDS) {
|
|
$cooldownRemaining = CRITTER_COOLDOWN_SECONDS - $secondsSinceLast;
|
|
}
|
|
|
|
if (!$dailyLimitReached && $cooldownRemaining > 0) {
|
|
$errors['cooldown'] = "Please wait {$cooldownRemaining} more second(s) before creating another critter.";
|
|
}
|
|
|
|
if (!$dailyLimitReached && empty($errors)) {
|
|
// 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 ($creditReserved) {
|
|
$insertStmt = $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, NULL, :datetime)');
|
|
$insertStmt->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'],
|
|
'datetime' => gmdate('Y-m-d H:i:s'),
|
|
]);
|
|
$critterId = (int) $pdo->lastInsertId();
|
|
}
|
|
|
|
$pdo->exec('COMMIT');
|
|
} catch (Throwable $e) {
|
|
$pdo->exec('ROLLBACK');
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
if ($creditReserved && $critterId !== null) {
|
|
$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);
|
|
// Extension is derived from the detected mime type below, so it always
|
|
// matches the actual bytes on disk instead of assuming every allowed
|
|
// type is webp.
|
|
$imageExtensionsByMime = [
|
|
'image/webp' => 'webp',
|
|
'image/png' => 'png',
|
|
'image/jpeg' => 'jpg',
|
|
];
|
|
$detectedMime = $imageInfo['mime'] ?? '';
|
|
|
|
if ($imageInfo !== false && isset($imageExtensionsByMime[$detectedMime])) {
|
|
$imagesDir = __DIR__ . '/critter-images';
|
|
if (!is_dir($imagesDir)) {
|
|
mkdir($imagesDir, 0755, true);
|
|
}
|
|
|
|
$candidateFilename = bin2hex(random_bytes(16)) . '.' . $imageExtensionsByMime[$detectedMime];
|
|
$bytesWritten = file_put_contents($imagesDir . '/' . $candidateFilename, $imageData);
|
|
if ($bytesWritten !== false) {
|
|
$imageFilename = $candidateFilename;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($imageFilename === null) {
|
|
// Every unsuccessful path above (request failure, failed/canceled prediction,
|
|
// disallowed or missing image URL, oversized or non-image download, or a failed
|
|
// write to disk) leaves $imageFilename null, so this single check refunds the
|
|
// reserved credit for all of them instead of only the initial request-failure case.
|
|
// The placeholder row is removed too, so a failed attempt doesn't also cost the
|
|
// user one of their daily critters or start a cooldown — refunding the credit but
|
|
// leaving the row behind would silently charge the daily/cooldown quota for
|
|
// something that, as far as the user is concerned, never happened.
|
|
$refundStmt = $pdo->prepare('UPDATE users SET credits = credits + 1 WHERE id = :id');
|
|
$refundStmt->execute(['id' => $_SESSION['user_id']]);
|
|
|
|
$deleteStmt = $pdo->prepare('DELETE FROM critters WHERE id = :id');
|
|
$deleteStmt->execute(['id' => $critterId]);
|
|
} else {
|
|
$updateStmt = $pdo->prepare('UPDATE critters SET image = :image WHERE id = :id');
|
|
$updateStmt->execute(['image' => $imageFilename, 'id' => $critterId]);
|
|
}
|
|
|
|
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 · Mix · 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…</h2>
|
|
<p id="creatingMessage" class="mt-2 text-gray-500 min-h-[1.5em]">Mixing colors…</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 — 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>
|