feat: Implement 'Surprise Me' worksheet generation, enhance SVG output quality with detailed prompt instructions, and display recent creations in the gallery.

This commit is contained in:
francy 2026-01-30 00:40:10 +01:00
parent f785bd8996
commit e5ccc82950
4 changed files with 139 additions and 52 deletions

View File

@ -14,6 +14,35 @@ app.use(express.json());
// Initialize Gemini API // Initialize Gemini API
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || ''); const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || '');
app.post('/api/surprise', async (req, res) => {
try {
if (!process.env.GEMINI_API_KEY) {
return res.status(500).json({ error: 'Missing API Key' });
}
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
const prompt = `
Generate ONE creative, fun, specific theme for a children's worksheet (e.g. "Space-Rex Coding", "Underwater Hamster Tea Party").
Pick a random age between 3 and 7.
Pick a random activity type from: coloring, numbers, writing, find-the-way, counting.
Return JSON ONLY:
{
"theme": "string",
"age": number,
"section": "string"
}
`;
const result = await model.generateContent(prompt);
const text = result.response.text();
const jsonStr = text.replace(/```json/g, '').replace(/```/g, '').trim();
res.json(JSON.parse(jsonStr));
} catch (error) {
console.error('Surprise error:', error);
res.status(500).json({ error: 'Failed' });
}
});
app.post('/api/generate', async (req, res) => { app.post('/api/generate', async (req, res) => {
try { try {
const { theme, age, sections } = req.body; const { theme, age, sections } = req.body;
@ -27,37 +56,53 @@ app.post('/api/generate', async (req, res) => {
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
const prompt = ` const prompt = `
Create educational worksheet metadata for a ${age}-year-old.
You are a professional illustrator of high-quality children's coloring books. Create ONLY clean, simple, bold-outline black-and-white SVGs in classic kid-book style: thick lines, large closed colorable areas, recognizable cute subjects, no distortions/blobs/weird features.
Create educational worksheet metadata for a ${age}-year-old child.
Theme: ${theme} Theme: ${theme}
Sections: ${sections.join(', ')} Sections: ${sections.join(', ')}
Return ONLY a valid JSON object. Return ONLY valid JSON. No other text, no explanations.
STRICT SVG REQUIREMENTS: MANDATORY STYLE & QUALITY RULES MUST FOLLOW EXACTLY:
1. Viewbox: '0 0 500 500'. - Depict the theme literally and recognizably using standard, cute children's illustration style (e.g. animals look like real animals with big eyes/smiles; no invented blobs, hybrids, pigtails/bows on wrong things, distorted proportions, extra random parts).
2. Style: Black stroke (#000000), white fill (#ffffff), no gradients/filters. - Every visible element MUST be FULLY CLOSED paths (use Z/z to close <path>, or <circle>/<ellipse>/<polygon>/<rect>). NO open paths, NO <line>, NO <polyline> unless closed, NO stroke-only lines without enclosed fill regions.
3. Pathing: All paths must be CLOSED and colorable. Use smooth curves. - Forbidden forever: blobs, distorted/wrong anatomy (e.g. elephant with pigtails, sheep with leaf legs), floating disconnected lines/parts, random dots/scribbles/noise/artifacts, self-intersecting paths making uncolorable slivers, cut-off/incomplete shapes, inconsistent stroke widths, tiny details, fill="none" on main objects (except holes), any greyscale/shading.
4. Completeness: DO NOT TRUNCATE. Ensure the SVG code is 100% complete and valid XML. - Stroke: #000000 solid (no dasharray except for traceable writing). Fill: #ffffff on all colorable regions.
5. Age-Appropriateness: - ViewBox: exactly '0 0 500 500'. Center content with 50100px margins on all sides. No touching edges.
- For 3-4: Use 8px strokes, bold outlines, and 'toddler-friendly' shapes (huge circles, squares). - Attributes: single quotes ' only for all XML.
- For 5-7: Use 3px strokes, add interior textures (scales, leaves, stars), and more realistic proportions. - SVG: complete valid XML <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'> ... </svg>. Use smooth curves (C, Q, S) for natural shapes. 820 large closed regions for coloring fun.
- High-quality kid style: bold thick outlines, big simple shapes, cute friendly expressions (big round eyes as circles/ovals, smiling curved mouth), balanced proportions (big head for cuteness), no complexity/noise.
JSON Structure: AGE-SPECIFIC RULES STRICT:
- For 34 years: Ultra-simple (12 huge objects max), stroke width 1016px, very large regions (410 total), cartoon-big features, no small details/textures/patterns inside.
- For 57 years: Slightly more (36 elements), stroke 48px, allow small closed interior shapes (e.g. spots as tiny circles), but still bold and clear.
SECTION-SPECIFIC LOGIC MUST BE EDUCATIONAL & COHERENT:
- colouring: One main cute subject (or 23 related) in simple pose/scene, many large closed areas (body parts, accessories), bold outlines, friendly look.
- numbers: Large closed-path digits (110 or similar) + theme-related countable objects grouped nearby.
- writing: Dotted/dashed traceable letters/numbers/words (use closed shapes for forms + dots/arrows for direction), clean practice space.
- find-the-way: Simple maze with closed barrier walls (rect/curved), clear start (arrow/circle) + end (goal object), theme elements along path, no traps.
- counting: Groups of identical closed objects (e.g. 310 apples) matching a shown number, tied to theme.
JSON exactly this structure:
{ {
"title": "Title", "title": "Short fun title",
"subtitle": "Subtitle", "subtitle": "Clear one-sentence kid instruction (e.g. Color this happy animal!)",
"sections": [ "sections": [
{ {
"id": "id", "id": "coloring-main | numbers-1-5 | writing-letters | find-the-way-maze | counting-objects",
"type": "coloring|writing|numbers", "type": "coloring|numbers|writing|find-the-way|counting",
"content_svg": "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>...</svg>" "content_svg": "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 500'>...FULL complete valid SVG code here...</svg>"
} }
// exactly one object per section in array
] ]
} }
CRITICAL: The 'content_svg' string must use SINGLE QUOTES for all XML attributes to prevent JSON parsing errors. CRITICAL: content_svg MUST be a string with single quotes in SVG attributes. Paths smooth and 100% closed.`;
`;
const result = await model.generateContent(prompt); const result = await model.generateContent(prompt);
const response = await result.response; const response = await result.response;
const text = response.text(); const text = response.text();

View File

@ -10,6 +10,8 @@ function App() {
const [generatedSheet, setGeneratedSheet] = React.useState(null); const [generatedSheet, setGeneratedSheet] = React.useState(null);
const [isGenerating, setIsGenerating] = React.useState(false); const [isGenerating, setIsGenerating] = React.useState(false);
const [history, setHistory] = React.useState([]);
const handleGenerate = async (data) => { const handleGenerate = async (data) => {
setIsGenerating(true); setIsGenerating(true);
try { try {
@ -25,6 +27,20 @@ function App() {
const result = await response.json(); const result = await response.json();
setGeneratedSheet(result); setGeneratedSheet(result);
// Add to history
const newHistoryItem = {
id: Date.now(),
title: result.title,
subtitle: result.subtitle,
age: data.age,
downloads: 0,
bgColor: 'bg-primary-50', // Default color
// We'll need to figure out how to preview it in the gallery.
// For now, Gallery expects 'bgColor'.
};
setHistory(prev => [newHistoryItem, ...prev]);
} catch (error) { } catch (error) {
console.error("Error generating sheet:", error); console.error("Error generating sheet:", error);
// Optional: Show error state // Optional: Show error state
@ -33,6 +49,9 @@ function App() {
} }
}; };
const galleryItems = history.length > 0 ? history.slice(0, 3) : popularSheets.slice(0, 3);
const galleryTitle = history.length > 0 ? "Your Recent Creations" : "Most Popular Printables";
return ( return (
<div className="min-h-screen bg-slate-50/50"> <div className="min-h-screen bg-slate-50/50">
{/* Navbar */} {/* Navbar */}
@ -55,7 +74,7 @@ function App() {
{/* Main Content */} {/* Main Content */}
<main className="max-w-7xl mx-auto px-4"> <main className="max-w-7xl mx-auto px-4">
<Hero onGenerate={handleGenerate} generatedSheet={generatedSheet} isGenerating={isGenerating} /> <Hero onGenerate={handleGenerate} generatedSheet={generatedSheet} isGenerating={isGenerating} />
<Gallery items={popularSheets} /> <Gallery items={galleryItems} title={galleryTitle} />
</main> </main>
<HowItWorks /> <HowItWorks />

View File

@ -3,11 +3,11 @@ import { motion } from 'framer-motion';
import { Wand2, Download, Star } from 'lucide-react'; import { Wand2, Download, Star } from 'lucide-react';
import { Button } from './ui/Button'; import { Button } from './ui/Button';
export const Gallery = ({ items }) => { export const Gallery = ({ items, title = "Most Popular Printables" }) => {
return ( return (
<section className="py-12"> <section className="py-12">
<div className="flex items-center gap-2 mb-8"> <div className="flex items-center gap-2 mb-8">
<h2 className="text-2xl font-bold text-slate-900">Most Popular Printables</h2> <h2 className="text-2xl font-bold text-slate-900">{title}</h2>
<Star className="text-yellow-400 fill-yellow-400" size={24} /> <Star className="text-yellow-400 fill-yellow-400" size={24} />
</div> </div>

View File

@ -10,38 +10,64 @@ import { sections } from '../data/mockData';
export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => { export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
const [theme, setTheme] = useState(''); const [theme, setTheme] = useState('');
const [age, setAge] = useState(5); const [age, setAge] = useState(5);
const [selectedSections, setSelectedSections] = useState(['coloring']); const [selectedSection, setSelectedSection] = useState('coloring');
const toggleSection = (id) => { const handleSectionChange = (id) => {
setSelectedSections(prev => if (!isGenerating) {
prev.includes(id) setSelectedSection(id);
? prev.filter(x => x !== id) }
: [...prev, id]
);
}; };
const handleSurpriseMe = () => { const handleSurpriseMe = async () => {
if (isGenerating) return; if (isGenerating) return;
const funnyThemes = ['Dinosaur Disco', 'Unicorn Space Party', 'Pirate Picnic', 'Robot Garden'];
const randomTheme = funnyThemes[Math.floor(Math.random() * funnyThemes.length)]; try {
setTheme(randomTheme); // Optional: indicate "thinking" state here if needed, or rely on parent loading state
setAge(Math.floor(Math.random() * 5) + 3); // since we don't have a separate loading state, we can reuse isGenerating logic or add one.
const randomSections = sections.slice(0, 3).map(s => s.id); // For now, we'll just set the values when they arrive.
setSelectedSections(randomSections);
const res = await fetch('http://localhost:3001/api/surprise', { method: 'POST' });
if (!res.ok) throw new Error('Surprise failed');
const idea = await res.json();
// Validate section ID exists in our map, else fallback
const validSection = sections.find(s => s.id === idea.section) ? idea.section : 'coloring';
setTheme(idea.theme);
setAge(idea.age);
setSelectedSection(validSection);
// Auto-generate for surprise // Auto-generate for surprise
if (onGenerate) {
onGenerate({
theme: idea.theme,
age: idea.age,
sections: [validSection]
});
}
} catch (err) {
console.error(err);
// Fallback to local random if network/AI fails
const funnyThemes = ['Dinosaur Disco', 'Unicorn Space Party', 'Pirate Picnic', 'Robot Garden'];
const randomTheme = funnyThemes[Math.floor(Math.random() * funnyThemes.length)];
setTheme(randomTheme);
setAge(Math.floor(Math.random() * 5) + 3);
const randomSection = sections[Math.floor(Math.random() * sections.length)].id;
setSelectedSection(randomSection);
if (onGenerate) { if (onGenerate) {
onGenerate({ onGenerate({
theme: randomTheme, theme: randomTheme,
age: Math.floor(Math.random() * 5) + 3, age: Math.floor(Math.random() * 5) + 3,
sections: randomSections sections: [randomSection]
}); });
} }
}
}; };
const handleCreate = () => { const handleCreate = () => {
if (onGenerate && !isGenerating) { if (onGenerate && !isGenerating) {
onGenerate({ theme, age, sections: selectedSections }); onGenerate({ theme, age, sections: [selectedSection] });
} }
}; };
@ -133,22 +159,19 @@ export const Hero = ({ onGenerate, generatedSheet, isGenerating }) => {
{/* Sections */} {/* Sections */}
<div className="space-y-3"> <div className="space-y-3">
<label className="font-semibold text-slate-700 block">Sections (Max 2)</label> <label className="font-semibold text-slate-700 block">Activity Type</label>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{sections.map(section => { {sections.map(section => {
const isSelected = selectedSections.includes(section.id); const isSelected = selectedSection === section.id;
const isDisabled = !isSelected && selectedSections.length >= 2;
return ( return (
<button <button
key={section.id} key={section.id}
onClick={() => !isGenerating && !isDisabled && toggleSection(section.id)} onClick={() => handleSectionChange(section.id)}
disabled={isGenerating || isDisabled} disabled={isGenerating}
className={` className={`
flex items-center gap-2 px-4 py-2 rounded-xl border-2 transition-all font-medium text-sm flex items-center gap-2 px-4 py-2 rounded-xl border-2 transition-all font-medium text-sm
${isSelected ${isSelected
? section.color + ' ring-2 ring-offset-1 ring-primary-200' ? section.color + ' ring-2 ring-offset-1 ring-primary-200'
: isDisabled
? 'bg-slate-50 border-slate-100 text-slate-300 cursor-not-allowed'
: 'bg-white border-slate-200 text-slate-600 hover:border-slate-300' : 'bg-white border-slate-200 text-slate-600 hover:border-slate-300'
} }
${isGenerating ? 'opacity-50 cursor-not-allowed' : ''} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}