feat: add AI pet name generator page and API endpoint
Client-side generator page with species (dog/cat) and theme selectors submits a job to /api/pet-names POST, then polls GET until completed. API uses nameGeneratorLimiter for rate limiting and delegates to queueNameGenerationJob/getJobStatus from lib/server/ai. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let species = $state('dog');
|
||||||
|
let theme = $state('classic');
|
||||||
|
let names = $state<string[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ value: 'classic', label: 'Classic' },
|
||||||
|
{ value: 'food', label: 'Foodie Names' },
|
||||||
|
{ value: 'disney', label: 'Disney' },
|
||||||
|
{ value: 'nature', label: 'Nature' },
|
||||||
|
{ value: 'funny', label: 'Funny & Punny' },
|
||||||
|
{ value: 'celebrity', label: 'Celebrity' },
|
||||||
|
{ value: 'random', label: 'Surprise Me!' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const thinkingMessages = [
|
||||||
|
'Our AI is thinking...',
|
||||||
|
'Brainstorming names...',
|
||||||
|
'Consulting the name archives...',
|
||||||
|
'Sniffing out good names...',
|
||||||
|
'Almost there...'
|
||||||
|
];
|
||||||
|
let thinkingIndex = $state(0);
|
||||||
|
let thinkingInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function startThinking() {
|
||||||
|
thinkingIndex = 0;
|
||||||
|
thinkingInterval = setInterval(() => {
|
||||||
|
thinkingIndex = (thinkingIndex + 1) % thinkingMessages.length;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopThinking() {
|
||||||
|
if (thinkingInterval) {
|
||||||
|
clearInterval(thinkingInterval);
|
||||||
|
thinkingInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
names = [];
|
||||||
|
startThinking();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Submit job
|
||||||
|
const submitRes = await fetch('/api/pet-names', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ species, theme, count: 10 })
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitData = await submitRes.json();
|
||||||
|
|
||||||
|
if (submitData.error) {
|
||||||
|
error = submitData.error;
|
||||||
|
loading = false;
|
||||||
|
stopThinking();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = submitData.jobId;
|
||||||
|
|
||||||
|
// Step 2: Poll for results
|
||||||
|
const maxAttempts = 60;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
const pollRes = await fetch(`/api/pet-names?jobId=${jobId}`);
|
||||||
|
const pollData = await pollRes.json();
|
||||||
|
|
||||||
|
if (pollData.status === 'completed') {
|
||||||
|
names = pollData.names ?? [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pollData.status === 'failed') {
|
||||||
|
error = pollData.error || 'Name generation failed. Please try again.';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts >= maxAttempts && names.length === 0 && !error) {
|
||||||
|
error = 'Request timed out. The AI might be busy — please try again in a moment.';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = 'Something went wrong. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
stopThinking();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyName(name: string) {
|
||||||
|
navigator.clipboard.writeText(name);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Pet Name Generator</title>
|
||||||
|
<meta name="description" content="Need a name for your new rescue pet? Our AI-powered name generator creates creative, themed pet names. Try it!" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="bg-gradient-to-br from-teal-700 via-teal-600 to-teal-700 py-16 text-center text-white">
|
||||||
|
<div class="mx-auto max-w-4xl px-4">
|
||||||
|
<h1 class="text-4xl font-bold sm:text-5xl">Pet Name Generator</h1>
|
||||||
|
<p class="mt-4 text-lg text-teal-100">
|
||||||
|
Can't decide on a name? Let our AI help! Powered by local AI — no data leaves our servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-gray-50 py-16">
|
||||||
|
<div class="mx-auto max-w-2xl px-4">
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 sm:p-8">
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<!-- Species -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-800 mb-3">I'm naming a...</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => species = 'dog'}
|
||||||
|
class="flex-1 py-3 px-4 rounded-xl text-center font-medium transition cursor-pointer {species === 'dog'
|
||||||
|
? 'bg-teal-600 text-white shadow-md'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
|
||||||
|
>
|
||||||
|
Dog
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => species = 'cat'}
|
||||||
|
class="flex-1 py-3 px-4 rounded-xl text-center font-medium transition cursor-pointer {species === 'cat'
|
||||||
|
? 'bg-teal-600 text-white shadow-md'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
|
||||||
|
>
|
||||||
|
Cat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-800 mb-3">Name theme</label>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
{#each themes as t}
|
||||||
|
<button
|
||||||
|
onclick={() => theme = t.value}
|
||||||
|
class="py-2.5 px-3 rounded-lg text-sm font-medium transition cursor-pointer {theme === t.value
|
||||||
|
? 'bg-teal-600 text-white shadow-md'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate Button -->
|
||||||
|
<button
|
||||||
|
onclick={generate}
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full py-4 rounded-xl bg-teal-600 text-white text-lg font-bold hover:bg-teal-700 disabled:opacity-50 transition shadow-lg hover:shadow-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{thinkingMessages[thinkingIndex]}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Generate Names!
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
{#if error}
|
||||||
|
<div class="mt-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
{#if names.length > 0}
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-bold text-gray-800 mb-4 text-center">
|
||||||
|
Here are your {species} names!
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
{#each names as name}
|
||||||
|
<button
|
||||||
|
onclick={() => copyName(name)}
|
||||||
|
class="group bg-white rounded-xl border border-gray-200 p-4 text-left hover:border-teal-500 hover:shadow-md transition cursor-pointer"
|
||||||
|
title="Click to copy"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-semibold text-gray-800 group-hover:text-teal-600">{name}</span>
|
||||||
|
<span class="block text-xs text-gray-400 mt-1 group-hover:text-teal-500/60">Click to copy</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="text-center text-xs text-gray-400 mt-4">
|
||||||
|
Generated by AI running locally
|
||||||
|
</p>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<button
|
||||||
|
onclick={generate}
|
||||||
|
class="text-teal-600 font-semibold hover:text-teal-700 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
Generate more →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Fun note -->
|
||||||
|
<div class="mt-12 text-center">
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Looking to adopt? Check out our
|
||||||
|
<a href="/pets" class="text-teal-600 font-medium hover:text-teal-700">available pets</a>
|
||||||
|
— they already have great names!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { queueNameGenerationJob, getJobStatus } from '$lib/server/ai';
|
||||||
|
import { nameGeneratorLimiter } from '$lib/server/rate-limit';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
||||||
|
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
|
||||||
|
|
||||||
|
// Rate limit: 10 requests per 5 minutes per IP
|
||||||
|
if (!nameGeneratorLimiter.check(ip)) {
|
||||||
|
return json({ error: 'Too many requests. Please slow down.' }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { species, theme, count } = await request.json();
|
||||||
|
|
||||||
|
const nameCount = Math.min(Math.max(parseInt(count) || 10, 1), 20);
|
||||||
|
const animalType = species === 'cat' ? 'cat' : 'dog';
|
||||||
|
|
||||||
|
const jobId = await queueNameGenerationJob({
|
||||||
|
species: animalType,
|
||||||
|
theme: theme || 'classic',
|
||||||
|
count: nameCount
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ jobId });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const jobId = parseInt(url.searchParams.get('jobId') || '0');
|
||||||
|
if (!jobId || isNaN(jobId)) {
|
||||||
|
return json({ error: 'Missing or invalid jobId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await getJobStatus(jobId);
|
||||||
|
if (!job) {
|
||||||
|
return json({ error: 'Job not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status === 'completed') {
|
||||||
|
let output = job.output as { names: string[] } | string | null;
|
||||||
|
if (typeof output === 'string') {
|
||||||
|
try { output = JSON.parse(output); } catch { output = null; }
|
||||||
|
}
|
||||||
|
const names = (output as { names: string[] } | null)?.names || [];
|
||||||
|
return json({ status: 'completed', names });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status === 'failed') {
|
||||||
|
return json({ status: 'failed', error: job.error || 'Unknown error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// pending or processing
|
||||||
|
return json({ status: job.status });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user