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:
parent
f785bd8996
commit
e5ccc82950
@ -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 50–100px 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. 8–20 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 3–4 years: Ultra-simple (1–2 huge objects max), stroke width 10–16px, very large regions (4–10 total), cartoon-big features, no small details/textures/patterns inside.
|
||||||
|
- For 5–7 years: Slightly more (3–6 elements), stroke 4–8px, 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 2–3 related) in simple pose/scene, many large closed areas (body parts, accessories), bold outlines, friendly look.
|
||||||
|
- numbers: Large closed-path digits (1–10 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. 3–10 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();
|
||||||
|
|||||||
21
src/App.jsx
21
src/App.jsx
@ -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 />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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' : ''}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user