Trudy commited on
Commit
5f07a23
·
0 Parent(s):

Initial commit with proper LFS tracking

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +36 -0
  2. .gitignore +37 -0
  3. Dockerfile +61 -0
  4. README.md +36 -0
  5. components/ActionBar.js +61 -0
  6. components/AddMaterialModal.js +484 -0
  7. components/ApiKeyModal.js +66 -0
  8. components/BottomToolBar.js +25 -0
  9. components/Canvas.js +1099 -0
  10. components/CanvasContainer.js +1651 -0
  11. components/DimensionSelector.js +130 -0
  12. components/DisplayCanvas.js +325 -0
  13. components/ErrorModal.js +100 -0
  14. components/Header.js +26 -0
  15. components/HeaderButtons.js +47 -0
  16. components/HistoryModal.js +113 -0
  17. components/ImageRefiner.js +41 -0
  18. components/LibraryPage.js +233 -0
  19. components/MaterialLibrary.tsx +65 -0
  20. components/OutputOptionsBar.js +67 -0
  21. components/StyleSelector.js +1571 -0
  22. components/TextInput.js +38 -0
  23. components/ToolBar.js +96 -0
  24. components/utils/canvasUtils.js +230 -0
  25. docker-compose.yml +18 -0
  26. jsconfig.json +7 -0
  27. next.config.js +36 -0
  28. package-lock.json +1626 -0
  29. package.json +31 -0
  30. pages/_app.js +5 -0
  31. pages/_document.js +13 -0
  32. pages/api/analyze-image.js +139 -0
  33. pages/api/convert-to-doodle.js +100 -0
  34. pages/api/enhance-material.js +108 -0
  35. pages/api/enhance-prompt.js +119 -0
  36. pages/api/generate-thumbnail.js +124 -0
  37. pages/api/generate.js +197 -0
  38. pages/api/hello.js +5 -0
  39. pages/api/refine.js +88 -0
  40. pages/api/validate-key.js +63 -0
  41. pages/api/visual-enhance-prompt.js +238 -0
  42. pages/index.js +18 -0
  43. public/GoogleSans/GoogleSans-Medium.ttf +3 -0
  44. public/GoogleSans/GoogleSans-MediumItalic.ttf +3 -0
  45. public/GoogleSans/GoogleSans-Regular.ttf +3 -0
  46. public/GoogleSans/GoogleSansDisplay-Bold.ttf +3 -0
  47. public/GoogleSans/GoogleSansDisplay-BoldItalic.ttf +3 -0
  48. public/GoogleSans/GoogleSansDisplay-Italic.ttf +3 -0
  49. public/GoogleSans/GoogleSansDisplay-Regular.ttf +3 -0
  50. public/favicon.ico +3 -0
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
3
+ # Binary formats tracked with LFS
4
+ *.png filter=lfs diff=lfs merge=lfs -text
5
+ *.jpg filter=lfs diff=lfs merge=lfs -text
6
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
7
+ *.gif filter=lfs diff=lfs merge=lfs -text
8
+ *.ico filter=lfs diff=lfs merge=lfs -text
9
+ *.pdf filter=lfs diff=lfs merge=lfs -text
10
+ # Fonts
11
+ *.ttf filter=lfs diff=lfs merge=lfs -text
12
+ *.otf filter=lfs diff=lfs merge=lfs -text
13
+ *.woff filter=lfs diff=lfs merge=lfs -text
14
+ *.woff2 filter=lfs diff=lfs merge=lfs -text
15
+ # Archives
16
+ *.zip filter=lfs diff=lfs merge=lfs -text
17
+ *.tar filter=lfs diff=lfs merge=lfs -text
18
+ *.tar.gz filter=lfs diff=lfs merge=lfs -text
19
+ *.7z filter=lfs diff=lfs merge=lfs -text
20
+ *.rar filter=lfs diff=lfs merge=lfs -text
21
+ # Models and binary data
22
+ *.bin filter=lfs diff=lfs merge=lfs -text
23
+ *.onnx filter=lfs diff=lfs merge=lfs -text
24
+ *.pth filter=lfs diff=lfs merge=lfs -text
25
+ *.h5 filter=lfs diff=lfs merge=lfs -text
26
+ *.model filter=lfs diff=lfs merge=lfs -text
27
+ *.pb filter=lfs diff=lfs merge=lfs -text
28
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
29
+ # Video/audio
30
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
31
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
32
+ *.mov filter=lfs diff=lfs merge=lfs -text
33
+ *.wav filter=lfs diff=lfs merge=lfs -text
34
+ # Mac specific
35
+ .DS_Store filter=lfs diff=lfs merge=lfs -text
36
+ public/**/* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dependencies
2
+ /node_modules
3
+ /.pnp
4
+ .pnp.js
5
+
6
+ # testing
7
+ /coverage
8
+
9
+ # next.js build outputs
10
+ /.next/
11
+ /out/
12
+
13
+ # production build outputs
14
+ /build
15
+
16
+ # misc
17
+ .DS_Store
18
+ *.pem
19
+
20
+ # debug
21
+ npm-debug.log*
22
+ yarn-debug.log*
23
+ yarn-error.log*
24
+
25
+ # local env files
26
+ .env*.local
27
+ .env
28
+
29
+ # vercel
30
+ .vercel
31
+
32
+ # typescript
33
+ *.tsbuildinfo
34
+ next-env.d.ts
35
+
36
+ # Large archive folders
37
+ /.next/cache/webpack/
Dockerfile ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Dependencies and Build
2
+ FROM node:18-alpine AS builder
3
+ WORKDIR /app
4
+
5
+ # Copy package files
6
+ COPY package.json package-lock.json* ./
7
+
8
+ # Install dependencies
9
+ RUN npm ci
10
+
11
+ # Copy application code
12
+ COPY . .
13
+
14
+ # Ensure next.config.js has output: 'standalone' setting
15
+ RUN if [ -f next.config.js ]; then \
16
+ # Check if next.config.js exists but doesn't have 'output: standalone'
17
+ if ! grep -q "output: 'standalone'" next.config.js && ! grep -q 'output: "standalone"' next.config.js; then \
18
+ # If it's a module
19
+ if grep -q "export" next.config.js; then \
20
+ echo "Updating module-type next.config.js with standalone output"; \
21
+ sed -i 's/export default/const nextConfig = /g' next.config.js && \
22
+ echo "nextConfig.output = 'standalone';\nexport default nextConfig;" >> next.config.js; \
23
+ else \
24
+ # If it's CommonJS
25
+ echo "Updating CommonJS next.config.js with standalone output"; \
26
+ sed -i 's/module.exports =/const nextConfig =/g' next.config.js && \
27
+ echo "nextConfig.output = 'standalone';\nmodule.exports = nextConfig;" >> next.config.js; \
28
+ fi; \
29
+ fi; \
30
+ else \
31
+ # Create next.config.js if it doesn't exist
32
+ echo "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n output: 'standalone'\n};\n\nmodule.exports = nextConfig;" > next.config.js; \
33
+ fi
34
+
35
+ # Build the application
36
+ RUN npm run build
37
+
38
+ # Stage 2: Production
39
+ FROM node:18-alpine AS runner
40
+ WORKDIR /app
41
+
42
+ # Set to production environment
43
+ ENV NODE_ENV production
44
+
45
+ # Create a non-root user to run the app and own app files
46
+ RUN addgroup --system --gid 1001 nodejs && \
47
+ adduser --system --uid 1001 nextjs
48
+
49
+ # Copy only necessary files from the builder stage
50
+ COPY --from=builder --chown=nextjs:nodejs /app/public ./public
51
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
52
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
53
+
54
+ # Switch to non-root user
55
+ USER nextjs
56
+
57
+ # Expose the port the app will run on
58
+ EXPOSE 3000
59
+
60
+ # Set the command to start the app
61
+ CMD ["node", "server.js"]
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Gemini 3D Co-Drawing
3
+ emoji: 🌖
4
+ colorFrom: yellow
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ license: apache-2.0
9
+ app_port: 3000
10
+ short_description: 'Gemini native image for 3D co-drawing'
11
+ ---
12
+
13
+ # Gemini 3D Co-Drawing
14
+
15
+ A collaborative drawing application powered by Google's Gemini 2.0 API for image generation. This app allows users to create drawings and have Gemini enhance or add to them based on text prompts.
16
+
17
+ ## API Key Setup
18
+
19
+ To use this application, you'll need a Google AI Studio API key:
20
+
21
+ 1. Visit [Google AI Studio](https://aistudio.google.com/app/apikey)
22
+ 2. Create a new API key or use an existing one
23
+ 3. Put your API key in a `.env` file.
24
+
25
+ ```
26
+ GEMINI_API_KEY={your_key}
27
+ ```
28
+
29
+ The free tier includes generous usage limits, but you can also upgrade for additional capacity.
30
+
31
+ ## Technology Stack
32
+
33
+ This is a [Next.js](https://nextjs.org) project that uses:
34
+ - Next.js for the frontend and API routes
35
+ - Google's Gemini 2.0 API for image generation
36
+ - Canvas API for drawing functionality
components/ActionBar.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Download, RefreshCw, History } from 'lucide-react';
2
+
3
+ const ActionBar = ({ handleSaveImage, handleRegenerate, onOpenHistory, hasGeneratedContent = false }) => {
4
+ return (
5
+ <div className="fixed bottom-4 right-4 flex gap-2">
6
+ <button
7
+ type="button"
8
+ onClick={handleRegenerate}
9
+ disabled={!hasGeneratedContent}
10
+ className={`group flex flex-col w-20 border border-gray-200 overflow-hidden rounded-xl transition-colors bg-gray-50 ${
11
+ hasGeneratedContent
12
+ ? 'hover:border-gray-300 hover:bg-white'
13
+ : 'opacity-50 cursor-not-allowed'
14
+ }`}
15
+ aria-label="Regenerate"
16
+ >
17
+ <div className="w-full relative flex items-center justify-center" style={{ aspectRatio: '1/1' }}>
18
+ <RefreshCw className={`w-6 h-6 text-gray-400 ${hasGeneratedContent ? 'group-hover:text-gray-600' : ''}`} />
19
+ </div>
20
+ <div className={`px-1 py-1 text-center text-xs font-medium text-gray-400 w-full ${hasGeneratedContent ? 'group-hover:text-gray-600' : ''}`}>
21
+ <div className="truncate">Regenerate</div>
22
+ </div>
23
+ </button>
24
+
25
+ <button
26
+ type="button"
27
+ onClick={onOpenHistory}
28
+ className="group flex flex-col w-20 border border-gray-200 overflow-hidden rounded-xl transition-colors bg-gray-50 hover:border-gray-300 hover:bg-white"
29
+ aria-label="View History"
30
+ >
31
+ <div className="w-full relative flex items-center justify-center" style={{ aspectRatio: '1/1' }}>
32
+ <History className="w-6 h-6 text-gray-400 group-hover:text-gray-600" />
33
+ </div>
34
+ <div className="px-1 py-1 text-center text-xs font-medium text-gray-400 w-full group-hover:text-gray-600">
35
+ <div className="truncate">History</div>
36
+ </div>
37
+ </button>
38
+
39
+ <button
40
+ type="button"
41
+ onClick={handleSaveImage}
42
+ disabled={!hasGeneratedContent}
43
+ className={`group flex flex-col w-20 border border-gray-200 overflow-hidden rounded-xl transition-colors bg-gray-50 ${
44
+ hasGeneratedContent
45
+ ? 'hover:border-gray-300 hover:bg-white'
46
+ : 'opacity-50 cursor-not-allowed'
47
+ }`}
48
+ aria-label="Save Image"
49
+ >
50
+ <div className="w-full relative flex items-center justify-center" style={{ aspectRatio: '1/1' }}>
51
+ <Download className={`w-6 h-6 text-gray-400 ${hasGeneratedContent ? 'group-hover:text-gray-600' : ''}`} />
52
+ </div>
53
+ <div className={`px-1 py-1 text-center text-xs font-medium text-gray-400 w-full ${hasGeneratedContent ? 'group-hover:text-gray-600' : ''}`}>
54
+ <div className="truncate">Save</div>
55
+ </div>
56
+ </button>
57
+ </div>
58
+ );
59
+ };
60
+
61
+ export default ActionBar;
components/AddMaterialModal.js ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState, useMemo } from 'react';
2
+ import { Upload, Edit, RefreshCw, RefreshCcw, Sparkles, ImageIcon, Plus, Check } from 'lucide-react';
3
+ import { addMaterialToLibrary } from './StyleSelector';
4
+
5
+ const AddMaterialModal = ({
6
+ showModal,
7
+ onClose,
8
+ onAddMaterial,
9
+ newMaterialName,
10
+ setNewMaterialName,
11
+ generatedMaterialName,
12
+ setGeneratedMaterialName,
13
+ generatedPrompt,
14
+ setGeneratedPrompt,
15
+ customPrompt,
16
+ setCustomPrompt,
17
+ previewThumbnail,
18
+ customImagePreview,
19
+ useCustomImage,
20
+ isGeneratingPreview,
21
+ isGeneratingText,
22
+ showMaterialNameEdit,
23
+ setShowMaterialNameEdit,
24
+ showCustomPrompt,
25
+ setShowCustomPrompt,
26
+ handleRefreshThumbnail,
27
+ handleReferenceImageUpload,
28
+ handleNewMaterialDescription,
29
+ onStyleSelected,
30
+ materials
31
+ }) => {
32
+ const fileInputRef = useRef(null);
33
+ const [isDragging, setIsDragging] = useState(false);
34
+ const [inputValue, setInputValue] = useState(newMaterialName);
35
+ const [hoveredMaterial, setHoveredMaterial] = useState(null);
36
+
37
+ // Sample materials for the library
38
+ const sampleMaterials = [
39
+ {
40
+ name: 'Topographic',
41
+ image: '/samples/topographic.jpeg',
42
+ prompt: "Transform this sketch into a sculptural form composed of precisely stacked, thin metallic rings or layers. Render it with warm copper/bronze tones with each layer maintaining equal spacing from adjacent layers, creating a topographic map effect. The form should appear to flow and undulate while maintaining the precise parallel structure. Use dramatic studio lighting against a pure black background to highlight the metallic luster and dimensional quality. Render it in a high-end 3D visualization style with perfect definition between each ring layer."
43
+ },
44
+ {
45
+ name: 'Glow Jelly',
46
+ image: '/samples/glowjelly.png',
47
+ prompt: "Transform this sketch into a boobly material. Render it in a high-end 3D visualization style with professional studio lighting against a pure black background. Make it look like an elegant Cinema 4D and Octane rendering with detailed material properties and characteristics. The final result should be an elegant visualization with perfect studio lighting, crisp shadows, and high-end material definition. Specifically, the boobly material should exhibit noticeable internal light scattering and a high degree of dynamic deformation on impact. To achieve the boobly effect, use a combination of Octane's Subsurface Scattering (SSS) and a Dynamic Mesh simulation in Cinema 4D, feeding the displacement output into the Octane material via an Octane Displacement node. The SSS should be tuned for a soft, fleshy translucency with a slight color shift towards red in thinner areas. Implement a high-frequency, low-amplitude noise pattern within the SSS radius to simulate subcutaneous textures. The material's surface should possess a subtle sheen, achieved through a thin-film interference effect in the Octane Glossy material, adding a rainbow iridescence that shifts with the viewing angle. The Dynamic Mesh should simulate realistic jiggle physics; consider using Cinema 4D's Soft Body Dynamics with carefully tuned spring and dampening values. Optimize the Octane scene for render efficiency, focusing on minimizing noise in the SSS and glossy reflections with adequate sample counts and kernel settings. The boobly material possesses a unique combination of squishiness and internal illumination, resulting in a captivating visual texture reminiscent of soft, organic forms. Rendering requires careful balancing of SSS settings and dynamic mesh calculations to achieve a realistic and visually appealing final result."
48
+ },
49
+ {
50
+ name: 'Gold',
51
+ image: '/samples/gold.png',
52
+ prompt: "Transform this sketch into a sculptural form composed of precisely stacked, thin metallic rings or layers. Render it with warm copper/bronze tones with each layer maintaining equal spacing from adjacent layers, creating a topographic map effect. The form should appear to flow and undulate while maintaining the precise parallel structure. Use dramatic studio lighting against a pure black background to highlight the metallic luster and dimensional quality. Render it in a high-end 3D visualization style with perfect definition between each ring layer."
53
+ },
54
+ {
55
+ name: 'Chinese Porcelain',
56
+ image: '/samples/porcelain.png',
57
+ prompt: "Transform this sketch into a ceramic vase featuring traditional blue and white Chinese porcelain designs, including dragons and cloud motifs. Pay close attention to the subtle variations in the blue pigment, creating a hand-painted effect with soft edges and intricate details. The vase should have a slightly glossy surface with gentle reflections. Render in Cinema 4D with Octane, using professional studio lighting to showcase the form and texture against a pure black background. Simulate subtle imperfections and surface irregularities typical of hand-crafted porcelain."
58
+ },
59
+ {
60
+ name: 'Voxels',
61
+ image: '/samples/voxels.png',
62
+ prompt: "Transform this sketch into a physical manifestation of 8-bit voxel art. The rendering should capture the distinct, blocky nature of the medium, with each 'voxel' clearly defined and possessing a matte, slightly rough surface texture. Render the object as if constructed from these individual, quantized blocks, highlighting the stepped edges and discrete color transitions inherent to the 8-bit aesthetic. The professional studio lighting should emphasize the geometric forms and reveal subtle variations in tone across the voxel surfaces. Use Cinema 4D and Octane to create a high-resolution visualization that showcases the material's unique properties against a pure black background, ensuring crisp shadows and a clear definition of each individual voxel. The final result should be an elegant visualization that celebrates the charm and simplicity of 8-bit graphics in a physical form."
63
+ },
64
+ {
65
+ name: 'Studio Ghibli',
66
+ image: '/samples/ghibli.png',
67
+ prompt: "Render the specific subject/scene/character detailed in the provided drawing/text. Faithfully interpret the core elements, composition, and figures present in the input. THEN, apply the following Studio Ghibli stylistic treatment: Aesthetic: Convert the rendering into the characteristic hand-painted look of Studio Ghibli films. Backgrounds: Use lush, painterly backgrounds that complement the subject (if the input lacks a background, create one in the Ghibli style; if it has one, render that background in the Ghibli style). Linework: Employ soft, expressive linework, avoiding harsh digital lines. Color: Utilize a warm, evocative, and harmonious color palette. Atmosphere: Infuse the scene with a Ghibli sense of wonder, nostalgia, or gentle melancholy, appropriate to the subject matter drawn. Details: Add subtle environmental details like wind, detailed foliage, or atmospheric effects typical of Ghibli, where suitable for the depicted scene. Lighting: Implement soft, natural, and atmospheric lighting (dappled sunlight, golden hour, overcast) integrated into the painted style. Goal: The final image must look like a high-quality Ghibli keyframe or background plate that is a clear and recognizable interpretation of the original provided drawing/text, not a generic Ghibli scene."
68
+ },
69
+ {
70
+ name: 'Purple Fur',
71
+ image: '/samples/purplefur.png',
72
+ prompt: "Transform this sketch into a 3D model with a stylized fur material, featuring long, soft, vibrant purple strands with subtle dark grey roots. Pay close attention to the direction and clumping of the fur to mimic the animal form's texture. Render it in Cinema 4D with Octane, using professional studio lighting against a pure black background to accentuate the texture and color depth of the fur, creating soft shadows and highlights."
73
+ },
74
+ {
75
+ name: 'Water',
76
+ image: '/samples/water.png',
77
+ prompt: "Transform this sketch into a dynamic water sculpture. Render it in a high-end 3D visualization style with professional studio lighting against a pure black background. Make it look like an elegant Cinema 4D and Octane rendering with detailed material properties and characteristics, capturing the essence of flowing water. The final result should be an elegant visualization with perfect studio lighting, crisp shadows, and high-end material definition, showcasing the water's transparency, refractive index, and subtle surface ripples. Emphasize the interplay of light and shadow as it passes through the water, highlighting its fluid and dynamic nature."
78
+ },
79
+ {
80
+ name: 'Shrek',
81
+ image: '/samples/shrek.png',
82
+ prompt: "Transform this sketch into a Shrek-like material, capturing the essence of his green, slightly bumpy skin texture. Render it in a high-end 3D visualization style with professional studio lighting against a pure black background. Emphasize the subtle variations in skin tone, the slight translucency that allows for subsurface scattering, and the almost mossy, organic feel. Make it look like an elegant Cinema 4D and Octane rendering with detailed material properties and characteristics, highlighting the way light interacts with the unique surface. The final result should be an elegant visualization with perfect studio lighting, crisp shadows, and high-end material definition, showcasing the Shrek-like skin properties."
83
+ },
84
+ ];
85
+
86
+ // Create a memoized list of already added material names
87
+ const addedMaterialNames = useMemo(() => {
88
+ if (!materials) return [];
89
+
90
+ // Extract all material names (convert to lowercase for case-insensitive comparison)
91
+ return Object.values(materials)
92
+ .map(material => material.name?.toLowerCase())
93
+ .filter(Boolean); // Filter out undefined/null
94
+ }, [materials]);
95
+
96
+ // Check if a material is already in the user's library
97
+ const isMaterialAlreadyAdded = (materialName) => {
98
+ return addedMaterialNames.includes(materialName.toLowerCase());
99
+ };
100
+
101
+ // Handle clicking outside of the modal to close it
102
+ const handleClickOutsideModal = (e) => {
103
+ if (e.target.classList.contains('modalBackdrop')) {
104
+ onClose();
105
+ }
106
+ };
107
+
108
+ // Handle selecting a material from the library
109
+ const handleAddMaterialFromLibrary = (material) => {
110
+ // Skip if already added
111
+ if (isMaterialAlreadyAdded(material.name)) {
112
+ return;
113
+ }
114
+
115
+ // Create a material object for the library
116
+ const materialObj = {
117
+ name: material.name,
118
+ prompt: material.prompt,
119
+ image: material.image
120
+ };
121
+
122
+ // Add material to library and get the key
123
+ const materialKey = addMaterialToLibrary(materialObj);
124
+
125
+ // Notify parent to select this material if the callback exists
126
+ if (onStyleSelected && typeof onStyleSelected === 'function') {
127
+ onStyleSelected(materialKey);
128
+ }
129
+
130
+ // Close the modal
131
+ onClose();
132
+ };
133
+
134
+ // Handle drag and drop events
135
+ const handleDragEnter = (e) => {
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ setIsDragging(true);
139
+ };
140
+
141
+ const handleDragLeave = (e) => {
142
+ e.preventDefault();
143
+ e.stopPropagation();
144
+ setIsDragging(false);
145
+ };
146
+
147
+ const handleDragOver = (e) => {
148
+ e.preventDefault();
149
+ e.stopPropagation();
150
+ };
151
+
152
+ const handleDrop = (e) => {
153
+ e.preventDefault();
154
+ e.stopPropagation();
155
+ setIsDragging(false);
156
+
157
+ const files = e.dataTransfer.files;
158
+ if (files.length > 0) {
159
+ const file = files[0];
160
+ if (file.type.startsWith('image/')) {
161
+ // Create a synthetic event to pass to handleReferenceImageUpload
162
+ const syntheticEvent = {
163
+ target: {
164
+ files: [file]
165
+ }
166
+ };
167
+ handleReferenceImageUpload(syntheticEvent);
168
+ } else {
169
+ alert('Please drop an image file');
170
+ }
171
+ }
172
+ };
173
+
174
+ // Create a debounced version of the material description handler
175
+ const handleSafeMaterialDescription = (description) => {
176
+ // Don't process if already generating
177
+ if (isGeneratingText || isGeneratingPreview) {
178
+ return;
179
+ }
180
+
181
+ // Update the name and generate material - this should trigger just one workflow
182
+ setNewMaterialName(description);
183
+ handleNewMaterialDescription(description);
184
+ };
185
+
186
+ if (!showModal) return null;
187
+
188
+ return (
189
+ <div
190
+ className="fixed inset-0 bg-black bg-opacity-30 h-screen flex items-center justify-center z-50 modalBackdrop overflow-y-auto p-0"
191
+ onClick={handleClickOutsideModal}
192
+ onKeyDown={(e) => {
193
+ if (e.key === 'Escape') {
194
+ onClose();
195
+ }
196
+ }}
197
+ role="dialog"
198
+ aria-modal="true"
199
+ >
200
+ <div className="bg-white rounded-xl shadow-medium w-full max-w-6xl mx-auto my-auto flex flex-col md:flex-row">
201
+ {/* Material Library Section */}
202
+ <div className="p-4 md:p-6 border-b md:border-b-0 md:border-r border-gray-100 w-full md:w-1/2 max-h-[50vh] md:max-h-none overflow-auto">
203
+ <div className="mb-3 md:mb-4">
204
+ <h2 className="text-lg md:text-xl font-medium text-gray-800" style={{ fontFamily: "'Google Sans Text', sans-serif" }}>Material Library</h2>
205
+ </div>
206
+
207
+ <div className="grid grid-cols-3 md:grid-cols-5 gap-2">
208
+ {sampleMaterials.map((material, index) => {
209
+ const isAlreadyAdded = isMaterialAlreadyAdded(material.name);
210
+ return (
211
+ <button
212
+ key={index}
213
+ onClick={() => handleAddMaterialFromLibrary(material)}
214
+ type="button"
215
+ aria-label={`Add ${material.name} material`}
216
+ className={`focus:outline-none group ${isAlreadyAdded ? 'opacity-50 cursor-not-allowed' : ''}`}
217
+ disabled={isAlreadyAdded}
218
+ >
219
+ <div className={`w-full border overflow-hidden rounded-xl ${isAlreadyAdded ? 'bg-gray-100 border-gray-200' : 'bg-gray-50 border-gray-200 group-hover:bg-white group-hover:border-gray-300'}`}>
220
+ <div className="w-full relative" style={{ aspectRatio: '1/1' }}>
221
+ <img
222
+ src={material.image}
223
+ alt={`${material.name} style example`}
224
+ className={`w-full h-full object-cover ${isAlreadyAdded ? 'opacity-70' : ''}`}
225
+ onError={(e) => {
226
+ console.error(`Error loading thumbnail for ${material.name}`);
227
+ e.target.src = '/samples/chrome.jpeg';
228
+ }}
229
+ />
230
+ <div className="absolute inset-0">
231
+ {isAlreadyAdded ? (
232
+ <div className="absolute inset-0 flex items-center justify-center">
233
+ <div className="flex items-center gap-1 bg-gray-500 px-2.5 py-1.5 text-white rounded-full text-xs font-medium">
234
+ <Check className="w-3 h-3" />
235
+ <span>Added</span>
236
+ </div>
237
+ </div>
238
+ ) : (
239
+ <div className="opacity-0 group-hover:opacity-100 hover:cursor-pointer transition-opacity">
240
+ <div className="absolute inset-0 bg-gray-500 bg-opacity-60 flex items-center justify-center">
241
+ <div className="flex items-center gap-1 bg-blue-500 px-2.5 py-1.5 text-white rounded-full text-xs font-medium">
242
+ <Plus className="w-3 h-3" />
243
+ <span>Add</span>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ )}
248
+ </div>
249
+ </div>
250
+ <div className={`px-1 py-1 text-left text-xs font-medium ${isAlreadyAdded ? 'bg-gray-100 text-gray-400' : 'bg-gray-50 text-gray-500 group-hover:bg-white group-hover:text-gray-600'}`}>
251
+ <div className="truncate">
252
+ {material.name}
253
+ </div>
254
+ </div>
255
+ </div>
256
+ </button>
257
+ );
258
+ })}
259
+ </div>
260
+ </div>
261
+
262
+ {/* Add Material Form Section */}
263
+ <div className="p-4 md:p-6 w-full md:w-1/2">
264
+ <div className="flex items-center justify-between mb-4 md:mb-6">
265
+ <h2 className="text-lg md:text-xl font-medium text-gray-800" style={{ fontFamily: "'Google Sans Text', sans-serif" }}>Add Material</h2>
266
+ </div>
267
+
268
+ {/* Input Section */}
269
+ <div className="mb-6 md:mb-8 pb-6 md:pb-8 border-b border-gray-100">
270
+ <div className="space-y-4">
271
+ {/* Text input */}
272
+ <div>
273
+ <label htmlFor="materialDescription" className="block text-sm font-medium text-gray-700 mb-1">
274
+ Describe your material
275
+ </label>
276
+ <div className="relative flex items-center">
277
+ <input
278
+ id="materialDescription"
279
+ type="text"
280
+ value={inputValue}
281
+ onChange={(e) => {
282
+ setInputValue(e.target.value);
283
+ }}
284
+ onKeyDown={(e) => {
285
+ if (e.key === 'Enter' && inputValue.trim()) {
286
+ e.preventDefault();
287
+ handleSafeMaterialDescription(inputValue);
288
+ }
289
+ }}
290
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black pr-10"
291
+ placeholder="Eg. Bubbles, glass etc"
292
+ disabled={isGeneratingText || isGeneratingPreview}
293
+ />
294
+ <button
295
+ type="button"
296
+ onClick={() => {
297
+ if (inputValue.trim() && !isGeneratingText && !isGeneratingPreview) {
298
+ handleSafeMaterialDescription(inputValue);
299
+ }
300
+ }}
301
+ className="absolute right-2 p-1.5 bg-blue-500 rounded-md hover:bg-blue-400 transition-colors"
302
+ title="Generate material preview"
303
+ disabled={isGeneratingText || isGeneratingPreview}
304
+ >
305
+ {isGeneratingText || isGeneratingPreview ? (
306
+ <RefreshCw className="w-5 h-5 text-white animate-spin" />
307
+ ) : (
308
+ <div className="flex text-sm text-white items-center gap-1">
309
+ <Sparkles className="w-4 h-4 text-white " /> Generate
310
+ </div>
311
+ )}
312
+ </button>
313
+ </div>
314
+ </div>
315
+
316
+ {/* Divider with "or" */}
317
+ <div className="relative">
318
+ <div className="absolute inset-0 flex items-center">
319
+ <div className="w-full border-t border-gray-200"></div>
320
+ </div>
321
+ <div className="relative flex justify-center text-sm">
322
+ <span className="px-2 bg-white text-gray-500">Or upload a reference image</span>
323
+ </div>
324
+ </div>
325
+
326
+ {/* Image upload */}
327
+ <div>
328
+ <div
329
+ className={`border-2 border-dashed rounded-lg p-4 flex flex-col items-center justify-center cursor-pointer transition-colors ${
330
+ isDragging
331
+ ? 'border-blue-500 bg-blue-50'
332
+ : 'border-gray-300 hover:bg-gray-50'
333
+ } ${isGeneratingText && useCustomImage ? 'opacity-70 pointer-events-none' : ''}`}
334
+ onClick={() => !isGeneratingText && fileInputRef.current?.click()}
335
+ onKeyDown={(e) => {
336
+ if (!isGeneratingText && (e.key === 'Enter' || e.key === ' ')) {
337
+ e.preventDefault();
338
+ fileInputRef.current?.click();
339
+ }
340
+ }}
341
+ onDragEnter={handleDragEnter}
342
+ onDragLeave={handleDragLeave}
343
+ onDragOver={handleDragOver}
344
+ onDrop={handleDrop}
345
+ style={{ minHeight: "100px" }}
346
+ role="button"
347
+ tabIndex={0}
348
+ >
349
+ {isGeneratingText && useCustomImage ? (
350
+ <div className="flex flex-col items-center gap-2 py-2">
351
+ <RefreshCw className="w-6 h-6 text-blue-500 animate-spin" />
352
+ <p className="text-sm text-blue-500 font-medium">Analyzing image...</p>
353
+ </div>
354
+ ) : (
355
+ <>
356
+ <div className="flex flex-col items-center gap-2">
357
+ <ImageIcon className={`w-6 h-6 ${isDragging ? 'text-blue-500' : 'text-gray-400'}`} />
358
+ <p className={`text-sm ${isDragging ? 'text-blue-500' : 'text-gray-500'} text-center font-medium`}>
359
+ {isDragging ? 'Drop your image here' : 'Upload your reference image here'}
360
+ </p>
361
+ </div>
362
+ <input
363
+ id="referenceImage"
364
+ ref={fileInputRef}
365
+ type="file"
366
+ accept="image/*"
367
+ className="hidden"
368
+ onChange={handleReferenceImageUpload}
369
+ disabled={isGeneratingText && useCustomImage}
370
+ />
371
+ </>
372
+ )}
373
+ </div>
374
+ </div>
375
+ </div>
376
+ </div>
377
+
378
+ {/* Review Section */}
379
+ {(generatedMaterialName || previewThumbnail) && (
380
+ <div className="mb-6 md:mb-8">
381
+ <div className="flex gap-4">
382
+ {/* Left column: Name and Prompt */}
383
+ <div className="flex-1 space-y-3">
384
+ {/* Material Name - smaller and lighter text */}
385
+ <div className="group relative">
386
+ <input
387
+ type="text"
388
+ value={generatedMaterialName}
389
+ onChange={(e) => setGeneratedMaterialName(e.target.value)}
390
+ className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg
391
+ focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white
392
+ transition-all text-gray-900 focus:text-gray-900 text-sm focus:text-base
393
+ placeholder-gray-400"
394
+ placeholder="Material Name"
395
+ />
396
+ </div>
397
+
398
+ {/* Prompt - smaller and lighter text */}
399
+ <div className="group relative">
400
+ <textarea
401
+ value={customPrompt || generatedPrompt}
402
+ onChange={(e) => setCustomPrompt(e.target.value)}
403
+ className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg
404
+ focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white
405
+ transition-all text-gray-900 text-sm
406
+ h-[100px] md:h-[180px] resize-none placeholder-gray-400 leading-relaxed"
407
+ placeholder="Prompt"
408
+ />
409
+ </div>
410
+ </div>
411
+
412
+ {/* Right column: Preview */}
413
+ <div className="w-28 md:w-36">
414
+ <div className="w-28 h-28 md:w-36 md:h-36 bg-gray-50 border border-gray-200 rounded-lg overflow-hidden relative flex items-center justify-center">
415
+ {/* Preview content */}
416
+ {((previewThumbnail || customImagePreview) && !isGeneratingPreview) ? (
417
+ <img
418
+ src={useCustomImage ? customImagePreview : previewThumbnail}
419
+ alt="Material preview"
420
+ className="w-full h-full object-cover"
421
+ />
422
+ ) : (
423
+ <div className="w-full h-full bg-white flex flex-col items-center justify-center">
424
+ {isGeneratingPreview ? (
425
+ <>
426
+ <RefreshCw className="w-6 h-6 md:w-8 md:h-8 text-blue-500 animate-spin mb-2" />
427
+ <p className="text-xs md:text-sm text-blue-500 text-center px-2 md:px-4">
428
+ Generating preview...
429
+ </p>
430
+ </>
431
+ ) : (
432
+ <p className="text-xs md:text-sm text-gray-400 text-center px-2 md:px-4">
433
+ Preview
434
+ </p>
435
+ )}
436
+ </div>
437
+ )}
438
+
439
+ {/* Refresh button */}
440
+ {(previewThumbnail || customImagePreview) && !isGeneratingPreview && !useCustomImage && (
441
+ <button
442
+ type="button"
443
+ onClick={() => {
444
+ if (!isGeneratingPreview) {
445
+ handleRefreshThumbnail(customPrompt);
446
+ }
447
+ }}
448
+ className="absolute top-2 right-2 bg-white/80 rounded-full p-1 hover:bg-white transition-colors"
449
+ title="Refresh thumbnail"
450
+ >
451
+ <RefreshCcw className="w-3 h-3 md:w-4 md:h-4 text-gray-600" />
452
+ </button>
453
+ )}
454
+ </div>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ )}
459
+
460
+ {/* Action buttons */}
461
+ <div className="flex justify-end gap-3 mt-4 md:mt-6">
462
+ <button
463
+ type="button"
464
+ onClick={onClose}
465
+ className="px-3 py-2 border border-gray-200 rounded-lg text-gray-600 hover:border-gray-300 hover:bg-gray-50 text-sm font-medium transition-colors"
466
+ >
467
+ Cancel
468
+ </button>
469
+ <button
470
+ type="button"
471
+ onClick={onAddMaterial}
472
+ disabled={isGeneratingPreview || !newMaterialName.trim()}
473
+ className="px-3 py-2 border border-blue-500 rounded-lg text-white bg-blue-500 hover:bg-blue-400 hover:cursor-pointer disabled:opacity-50 text-sm font-medium transition-colors"
474
+ >
475
+ + Add Material
476
+ </button>
477
+ </div>
478
+ </div>
479
+ </div>
480
+ </div>
481
+ );
482
+ };
483
+
484
+ export default AddMaterialModal;
components/ApiKeyModal.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+
3
+ const ApiKeyModal = ({ isOpen, onClose, onSubmit, initialValue = '' }) => {
4
+ const [apiKey, setApiKey] = useState(initialValue);
5
+
6
+ if (!isOpen) return null;
7
+
8
+ return (
9
+ <div
10
+ className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50 modalBackdrop"
11
+ onClick={(e) => {
12
+ if (e.target.classList.contains('modalBackdrop')) {
13
+ onClose();
14
+ }
15
+ }}
16
+ >
17
+ <div className="bg-white p-6 rounded-xl shadow-medium max-w-md w-full">
18
+ <h2 className="text-xl font-medium text-gray-800 mb-4">API Key Required</h2>
19
+
20
+ <p className="text-sm text-gray-600 mb-6">
21
+ You've reached the limit of free Gemini API calls. To continue using this feature, please enter your own Gemini API key below.
22
+ You can get a free API key from <a href="https://ai.google.dev/" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">Google AI Studio</a>.
23
+ </p>
24
+
25
+ <form onSubmit={(e) => {
26
+ e.preventDefault();
27
+ onSubmit(apiKey);
28
+ }}>
29
+ <div className="mb-4">
30
+ <label htmlFor="apiKey" className="block text-sm font-medium text-gray-700 mb-1">
31
+ Gemini API Key
32
+ </label>
33
+ <input
34
+ id="apiKey"
35
+ type="text"
36
+ value={apiKey}
37
+ onChange={(e) => setApiKey(e.target.value)}
38
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
39
+ placeholder="AIza..."
40
+ required
41
+ />
42
+ </div>
43
+
44
+ <div className="flex justify-end gap-3 mt-6">
45
+ <button
46
+ type="button"
47
+ onClick={onClose}
48
+ className="px-3 py-2 border border-gray-200 rounded-lg text-gray-600 hover:border-gray-300 hover:bg-gray-50 text-sm font-medium"
49
+ >
50
+ Cancel
51
+ </button>
52
+ <button
53
+ type="submit"
54
+ disabled={!apiKey.trim()}
55
+ className="px-3 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-sm font-medium"
56
+ >
57
+ Save API Key
58
+ </button>
59
+ </div>
60
+ </form>
61
+ </div>
62
+ </div>
63
+ );
64
+ };
65
+
66
+ export default ApiKeyModal;
components/BottomToolBar.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Plus, HelpCircle } from 'lucide-react';
2
+
3
+ const BottomToolBar = () => {
4
+ const tools = [
5
+ { id: 'add', icon: Plus, label: 'Add' },
6
+ { id: 'help', icon: HelpCircle, label: 'Help' }
7
+ ];
8
+
9
+ return (
10
+ <div className="flex flex-col gap-2 rounded-xl p-2 opacity-0">
11
+ {tools.map((tool) => (
12
+ <div key={tool.id} className="relative">
13
+ <button
14
+ className="p-2 rounded-lg"
15
+ title={tool.label}
16
+ >
17
+ <tool.icon className="w-5 h-5" />
18
+ </button>
19
+ </div>
20
+ ))}
21
+ </div>
22
+ );
23
+ };
24
+
25
+ export default BottomToolBar;
components/Canvas.js ADDED
@@ -0,0 +1,1099 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useEffect, useState, forwardRef, useImperativeHandle, useCallback } from 'react';
2
+ import {
3
+ getCoordinates,
4
+ drawBezierCurve,
5
+ drawBezierGuides,
6
+ createAnchorPoint,
7
+ isNearHandle,
8
+ updateHandle
9
+ } from './utils/canvasUtils';
10
+ import { PencilLine, Upload, ImagePlus, LoaderCircle, Brush } from 'lucide-react';
11
+ import ToolBar from './ToolBar';
12
+ import StyleSelector from './StyleSelector';
13
+
14
+ const Canvas = forwardRef(({
15
+ canvasRef,
16
+ currentTool,
17
+ isDrawing,
18
+ startDrawing,
19
+ draw,
20
+ stopDrawing,
21
+ handleCanvasClick,
22
+ handlePenClick,
23
+ handleGeneration,
24
+ tempPoints,
25
+ setTempPoints,
26
+ handleUndo,
27
+ clearCanvas,
28
+ setCurrentTool,
29
+ currentDimension,
30
+ onImageUpload,
31
+ onGenerate,
32
+ isGenerating,
33
+ setIsGenerating,
34
+ currentColor,
35
+ currentWidth,
36
+ handleStrokeWidth,
37
+ saveCanvasState,
38
+ onDrawingChange,
39
+ styleMode,
40
+ setStyleMode,
41
+ isSendingToDoodle,
42
+ }, ref) => {
43
+ const [showBezierGuides, setShowBezierGuides] = useState(true);
44
+ const [activePoint, setActivePoint] = useState(-1);
45
+ const [activeHandle, setActiveHandle] = useState(null);
46
+ const [symmetric, setSymmetric] = useState(true);
47
+ const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 });
48
+ const [hasDrawing, setHasDrawing] = useState(false);
49
+ const [strokeCount, setStrokeCount] = useState(0);
50
+ const fileInputRef = useRef(null);
51
+ const [shapeStartPos, setShapeStartPos] = useState(null);
52
+ const [previewCanvas, setPreviewCanvas] = useState(null);
53
+ const [isDoodleConverting, setIsDoodleConverting] = useState(false);
54
+ const [uploadedImages, setUploadedImages] = useState([]);
55
+ const [draggingImage, setDraggingImage] = useState(null);
56
+ const [resizingImage, setResizingImage] = useState(null);
57
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
58
+ const [isDraggingFile, setIsDraggingFile] = useState(false);
59
+ const canvasContainerRef = useRef(null);
60
+
61
+ // Create a ref to track the previous style mode
62
+ const prevStyleModeRef = useRef(styleMode);
63
+
64
+ // Stable callback reference for handleGeneration
65
+ const handleGenerationRef = useRef(handleGeneration);
66
+ useEffect(() => {
67
+ handleGenerationRef.current = handleGeneration;
68
+ }, [handleGeneration]);
69
+
70
+ // Add effect to watch for styleMode changes and trigger generation
71
+ useEffect(() => {
72
+ // Skip the first render
73
+ if (prevStyleModeRef.current === styleMode) {
74
+ return;
75
+ }
76
+
77
+ // Update the ref to current value
78
+ prevStyleModeRef.current = styleMode;
79
+
80
+ // When styleMode changes, trigger generation
81
+ if (typeof handleGenerationRef.current === 'function') {
82
+ handleGenerationRef.current();
83
+ }
84
+ }, [styleMode]);
85
+
86
+ // Add touch event prevention function
87
+ useEffect(() => {
88
+ // Function to prevent default touch behavior on canvas
89
+ const preventTouchDefault = (e) => {
90
+ if (isDrawing) {
91
+ e.preventDefault();
92
+ }
93
+ };
94
+
95
+ // Add event listener when component mounts
96
+ const canvas = canvasRef.current;
97
+ if (canvas) {
98
+ canvas.addEventListener('touchstart', preventTouchDefault, { passive: false });
99
+ canvas.addEventListener('touchmove', preventTouchDefault, { passive: false });
100
+ }
101
+
102
+ // Remove event listener when component unmounts
103
+ return () => {
104
+ if (canvas) {
105
+ canvas.removeEventListener('touchstart', preventTouchDefault);
106
+ canvas.removeEventListener('touchmove', preventTouchDefault);
107
+ }
108
+ };
109
+ }, [isDrawing, canvasRef]);
110
+
111
+ // Add debugging info to console
112
+ useEffect(() => {
113
+ console.log('Canvas tool changed or isDrawing changed:', { currentTool, isDrawing });
114
+ }, [currentTool, isDrawing]);
115
+
116
+ // Add effect to rerender when uploadedImages change
117
+ useEffect(() => {
118
+ if (uploadedImages.length > 0) {
119
+ renderCanvas();
120
+ }
121
+ }, [uploadedImages]);
122
+
123
+ // Redraw bezier guides and control points when tempPoints change
124
+ useEffect(() => {
125
+ if (currentTool === 'pen' && tempPoints.length > 0 && showBezierGuides) {
126
+ redrawBezierGuides();
127
+ }
128
+ }, [tempPoints, showBezierGuides, currentTool]);
129
+
130
+ // Add useEffect to check if canvas has content
131
+ useEffect(() => {
132
+ const canvas = canvasRef.current;
133
+ if (!canvas) return;
134
+
135
+ const ctx = canvas.getContext('2d');
136
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
137
+
138
+ // Check if canvas has any non-white pixels (i.e., has a drawing)
139
+ const hasNonWhitePixels = Array.from(imageData.data).some((pixel, index) => {
140
+ // Check only RGB values (skip alpha)
141
+ return index % 4 !== 3 && pixel !== 255;
142
+ });
143
+
144
+ setHasDrawing(hasNonWhitePixels);
145
+ }, [canvasRef]);
146
+
147
+ // Add this near your other useEffects
148
+ useEffect(() => {
149
+ // When isDoodleConverting becomes true, also set hasDrawing to true
150
+ if (isDoodleConverting) {
151
+ setHasDrawing(true);
152
+ }
153
+ }, [isDoodleConverting]);
154
+
155
+ // Create a stable ref for handleFileChange to avoid dependency cycles
156
+ const handleFileChangeRef = useRef(null);
157
+
158
+ // Update handleFileChange function
159
+ const handleFileChange = useCallback(async (event) => {
160
+ const file = event.target.files?.[0];
161
+ if (!file) return;
162
+
163
+ // Store the current tool
164
+ const previousTool = currentTool;
165
+
166
+ // Hide the placeholder immediately when upload begins
167
+ if (typeof onDrawingChange === 'function') {
168
+ onDrawingChange(true);
169
+ }
170
+
171
+ // Show loading state
172
+ setIsDoodleConverting(true);
173
+
174
+ const reader = new FileReader();
175
+ reader.onload = async (e) => {
176
+ const imageDataUrl = e.target.result;
177
+
178
+ try {
179
+ // Compress the image before sending
180
+ const compressedImage = await compressImage(imageDataUrl);
181
+
182
+ const response = await fetch('/api/convert-to-doodle', {
183
+ method: 'POST',
184
+ headers: {
185
+ 'Content-Type': 'application/json',
186
+ },
187
+ body: JSON.stringify({
188
+ imageData: compressedImage.split(",")[1],
189
+ }),
190
+ });
191
+
192
+ const data = await response.json();
193
+
194
+ if (data.success && data.imageData) {
195
+ const img = new Image();
196
+ img.onload = () => {
197
+ const ctx = canvasRef.current.getContext('2d');
198
+
199
+ // Clear canvas
200
+ ctx.fillStyle = '#FFFFFF';
201
+ ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
202
+
203
+ // Calculate dimensions
204
+ const scale = Math.min(
205
+ canvasRef.current.width / img.width,
206
+ canvasRef.current.height / img.height
207
+ );
208
+ const x = (canvasRef.current.width - img.width * scale) / 2;
209
+ const y = (canvasRef.current.height - img.height * scale) / 2;
210
+
211
+ // Draw doodle
212
+ ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
213
+
214
+ // Save canvas state
215
+ saveCanvasState();
216
+
217
+ // Hide loading state
218
+ setIsDoodleConverting(false);
219
+
220
+ // Ensure placeholder is hidden
221
+ if (typeof onDrawingChange === 'function') {
222
+ onDrawingChange(true);
223
+ }
224
+
225
+ // Automatically trigger generation
226
+ handleGenerationRef.current();
227
+ };
228
+
229
+ img.src = `data:image/png;base64,${data.imageData}`;
230
+ }
231
+ } catch (error) {
232
+ console.error('Error processing image:', error);
233
+ setIsDoodleConverting(false);
234
+ alert('Error processing image. Please try a different image or a smaller file size.');
235
+
236
+ // Restore previous tool even if there's an error
237
+ setCurrentTool(previousTool);
238
+ }
239
+ };
240
+
241
+ reader.readAsDataURL(file);
242
+ }, [canvasRef, currentTool, onDrawingChange, saveCanvasState, setCurrentTool, setIsDoodleConverting]);
243
+
244
+ // Keep the ref updated
245
+ useEffect(() => {
246
+ handleFileChangeRef.current = handleFileChange;
247
+ }, [handleFileChange]);
248
+
249
+ // Add drag and drop event handlers
250
+ useEffect(() => {
251
+ const container = canvasContainerRef.current;
252
+ if (!container) return;
253
+
254
+ const handleDragEnter = (e) => {
255
+ e.preventDefault();
256
+ e.stopPropagation();
257
+ setIsDraggingFile(true);
258
+ };
259
+
260
+ const handleDragOver = (e) => {
261
+ e.preventDefault();
262
+ e.stopPropagation();
263
+ if (!isDraggingFile) setIsDraggingFile(true);
264
+ };
265
+
266
+ const handleDragLeave = (e) => {
267
+ e.preventDefault();
268
+ e.stopPropagation();
269
+
270
+ // Only set to false if we're leaving the container (not entering a child)
271
+ if (e.currentTarget === container && !container.contains(e.relatedTarget)) {
272
+ setIsDraggingFile(false);
273
+ }
274
+ };
275
+
276
+ const handleDrop = (e) => {
277
+ e.preventDefault();
278
+ e.stopPropagation();
279
+ setIsDraggingFile(false);
280
+
281
+ const files = e.dataTransfer.files;
282
+ if (files.length > 0) {
283
+ const file = files[0];
284
+
285
+ // Check if it's an image
286
+ if (file.type.startsWith('image/')) {
287
+ // Create a fake event object to reuse the existing handleFileChange function
288
+ const fakeEvent = { target: { files: [file] } };
289
+ if (handleFileChangeRef.current) {
290
+ handleFileChangeRef.current(fakeEvent);
291
+ }
292
+ }
293
+ }
294
+ };
295
+
296
+ container.addEventListener('dragenter', handleDragEnter);
297
+ container.addEventListener('dragover', handleDragOver);
298
+ container.addEventListener('dragleave', handleDragLeave);
299
+ container.addEventListener('drop', handleDrop);
300
+
301
+ return () => {
302
+ container.removeEventListener('dragenter', handleDragEnter);
303
+ container.removeEventListener('dragover', handleDragOver);
304
+ container.removeEventListener('dragleave', handleDragLeave);
305
+ container.removeEventListener('drop', handleDrop);
306
+ };
307
+ }, [isDraggingFile]);
308
+
309
+ const handleKeyDown = (e) => {
310
+ // Add keyboard accessibility
311
+ if (e.key === 'Enter' || e.key === ' ') {
312
+ handleCanvasClick(e);
313
+ }
314
+
315
+ // Toggle symmetric handles with Shift key
316
+ if (e.key === 'Shift') {
317
+ setSymmetric(!symmetric);
318
+ }
319
+ };
320
+
321
+ // Draw bezier control points and guide lines
322
+ const redrawBezierGuides = () => {
323
+ const canvas = canvasRef.current;
324
+ if (!canvas) return;
325
+
326
+ // Get the canvas context
327
+ const ctx = canvas.getContext('2d');
328
+
329
+ // Save the current canvas state to redraw later
330
+ const canvasImage = new Image();
331
+ canvasImage.src = canvas.toDataURL();
332
+
333
+ canvasImage.onload = () => {
334
+ // Clear canvas
335
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
336
+
337
+ // Redraw the canvas content
338
+ ctx.drawImage(canvasImage, 0, 0);
339
+
340
+ // Draw the control points and guide lines
341
+ drawBezierGuides(ctx, tempPoints);
342
+ };
343
+ };
344
+
345
+ // Function to draw a star shape
346
+ const drawStar = (ctx, x, y, radius, points = 5) => {
347
+ ctx.beginPath();
348
+ for (let i = 0; i <= points * 2; i++) {
349
+ const r = i % 2 === 0 ? radius : radius / 2;
350
+ const angle = (i * Math.PI) / points;
351
+ const xPos = x + r * Math.sin(angle);
352
+ const yPos = y + r * Math.cos(angle);
353
+ if (i === 0) ctx.moveTo(xPos, yPos);
354
+ else ctx.lineTo(xPos, yPos);
355
+ }
356
+ ctx.closePath();
357
+ };
358
+
359
+ // Function to draw shapes
360
+ const drawShape = (ctx, startPos, endPos, shape, isPreview = false) => {
361
+ if (!startPos || !endPos) return;
362
+
363
+ const width = endPos.x - startPos.x;
364
+ const height = endPos.y - startPos.y;
365
+ const radius = Math.sqrt(width * width + height * height) / 2;
366
+
367
+ ctx.strokeStyle = currentColor || '#000000';
368
+ ctx.fillStyle = currentColor || '#000000';
369
+ ctx.lineWidth = currentWidth || 2;
370
+
371
+ switch (shape) {
372
+ case 'rect':
373
+ if (isPreview) {
374
+ ctx.strokeRect(startPos.x, startPos.y, width, height);
375
+ } else {
376
+ ctx.fillRect(startPos.x, startPos.y, width, height);
377
+ }
378
+ break;
379
+ case 'circle':
380
+ ctx.beginPath();
381
+ ctx.ellipse(
382
+ startPos.x + width / 2,
383
+ startPos.y + height / 2,
384
+ Math.abs(width / 2),
385
+ Math.abs(height / 2),
386
+ 0,
387
+ 0,
388
+ 2 * Math.PI
389
+ );
390
+ if (isPreview) {
391
+ ctx.stroke();
392
+ } else {
393
+ ctx.fill();
394
+ }
395
+ break;
396
+ case 'line':
397
+ ctx.beginPath();
398
+ ctx.lineCap = 'round';
399
+ ctx.lineWidth = currentWidth * 2 || 4; // Make lines thicker
400
+ ctx.moveTo(startPos.x, startPos.y);
401
+ ctx.lineTo(endPos.x, endPos.y);
402
+ ctx.stroke();
403
+ break;
404
+ case 'star': {
405
+ const centerX = startPos.x + width / 2;
406
+ const centerY = startPos.y + height / 2;
407
+ drawStar(ctx, centerX, centerY, radius);
408
+ if (isPreview) {
409
+ ctx.stroke();
410
+ } else {
411
+ ctx.fill();
412
+ }
413
+ break;
414
+ }
415
+ }
416
+ };
417
+
418
+ // Add this new renderCanvas function after handleFileChange
419
+ const renderCanvas = useCallback(() => {
420
+ const canvas = canvasRef.current;
421
+ if (!canvas) return;
422
+
423
+ const ctx = canvas.getContext('2d');
424
+
425
+ // Store current canvas state in a temporary canvas to preserve drawings
426
+ const tempCanvas = document.createElement('canvas');
427
+ tempCanvas.width = canvas.width;
428
+ tempCanvas.height = canvas.height;
429
+ const tempCtx = tempCanvas.getContext('2d');
430
+ tempCtx.drawImage(canvas, 0, 0);
431
+
432
+ // Clear canvas
433
+ ctx.fillStyle = '#FFFFFF';
434
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
435
+
436
+ // Redraw original content
437
+ ctx.drawImage(tempCanvas, 0, 0);
438
+
439
+ // Draw all uploaded images
440
+ for (const img of uploadedImages) {
441
+ const imageObj = new Image();
442
+ imageObj.src = img.src;
443
+ ctx.drawImage(imageObj, img.x, img.y, img.width, img.height);
444
+
445
+ // Draw selection handles if dragging or resizing this image
446
+ if (draggingImage === img.id || resizingImage === img.id) {
447
+ // Draw border
448
+ ctx.strokeStyle = '#0080ff';
449
+ ctx.lineWidth = 2;
450
+ ctx.strokeRect(img.x, img.y, img.width, img.height);
451
+
452
+ // Draw corner resize handles
453
+ ctx.fillStyle = '#0080ff';
454
+ const handleSize = 8;
455
+
456
+ // Top-left
457
+ ctx.fillRect(img.x - handleSize/2, img.y - handleSize/2, handleSize, handleSize);
458
+ // Top-right
459
+ ctx.fillRect(img.x + img.width - handleSize/2, img.y - handleSize/2, handleSize, handleSize);
460
+ // Bottom-left
461
+ ctx.fillRect(img.x - handleSize/2, img.y + img.height - handleSize/2, handleSize, handleSize);
462
+ // Bottom-right
463
+ ctx.fillRect(img.x + img.width - handleSize/2, img.y + img.height - handleSize/2, handleSize, handleSize);
464
+ }
465
+ }
466
+ }, [canvasRef, uploadedImages, draggingImage, resizingImage]);
467
+
468
+ // Handle mouse down for image interaction
469
+ const handleImageMouseDown = (e) => {
470
+ if (currentTool !== 'selection') return false;
471
+
472
+ const { x, y } = getCoordinates(e, canvasRef.current);
473
+ const handleSize = 8;
474
+
475
+ // Check if clicked on any image handle first (for resizing)
476
+ for (let i = uploadedImages.length - 1; i >= 0; i--) {
477
+ const img = uploadedImages[i];
478
+
479
+ // Check if clicked on bottom-right resize handle
480
+ if (
481
+ x >= img.x + img.width - handleSize/2 - 5 &&
482
+ x <= img.x + img.width + handleSize/2 + 5 &&
483
+ y >= img.y + img.height - handleSize/2 - 5 &&
484
+ y <= img.y + img.height + handleSize/2 + 5
485
+ ) {
486
+ setResizingImage(img.id);
487
+ setDragOffset({ x: x - (img.x + img.width), y: y - (img.y + img.height) });
488
+ return true;
489
+ }
490
+ }
491
+
492
+ // If not resizing, check if clicked on any image (for dragging)
493
+ for (let i = uploadedImages.length - 1; i >= 0; i--) {
494
+ const img = uploadedImages[i];
495
+ if (
496
+ x >= img.x &&
497
+ x <= img.x + img.width &&
498
+ y >= img.y &&
499
+ y <= img.y + img.height
500
+ ) {
501
+ setDraggingImage(img.id);
502
+ setDragOffset({ x: x - img.x, y: y - img.y });
503
+ return true;
504
+ }
505
+ }
506
+
507
+ return false;
508
+ };
509
+
510
+ // Handle mouse move for image interaction
511
+ const handleImageMouseMove = (e) => {
512
+ if (!draggingImage && !resizingImage) return false;
513
+
514
+ const { x, y } = getCoordinates(e, canvasRef.current);
515
+
516
+ if (draggingImage) {
517
+ // Update position of dragged image
518
+ setUploadedImages(prev => prev.map(img => {
519
+ if (img.id === draggingImage) {
520
+ return {
521
+ ...img,
522
+ x: x - dragOffset.x,
523
+ y: y - dragOffset.y
524
+ };
525
+ }
526
+ return img;
527
+ }));
528
+
529
+ renderCanvas();
530
+ return true;
531
+ }
532
+
533
+ if (resizingImage) {
534
+ // Update size of resized image
535
+ setUploadedImages(prev => prev.map(img => {
536
+ if (img.id === resizingImage) {
537
+ // Calculate new width and height
538
+ const newWidth = Math.max(20, x - img.x - dragOffset.x + 10);
539
+ const newHeight = Math.max(20, y - img.y - dragOffset.y + 10);
540
+
541
+ // Option 1: Free resize
542
+ return {
543
+ ...img,
544
+ width: newWidth,
545
+ height: newHeight
546
+ };
547
+
548
+ // Option 2: Maintain aspect ratio (uncomment if needed)
549
+ /*
550
+ const aspectRatio = img.originalWidth / img.originalHeight;
551
+ const newHeight = newWidth / aspectRatio;
552
+ return {
553
+ ...img,
554
+ width: newWidth,
555
+ height: newHeight
556
+ };
557
+ */
558
+ }
559
+ return img;
560
+ }));
561
+
562
+ renderCanvas();
563
+ return true;
564
+ }
565
+
566
+ return false;
567
+ };
568
+
569
+ // Handle mouse up for image interaction
570
+ const handleImageMouseUp = () => {
571
+ if (draggingImage || resizingImage) {
572
+ setDraggingImage(null);
573
+ setResizingImage(null);
574
+ saveCanvasState();
575
+ return true;
576
+ }
577
+ return false;
578
+ };
579
+
580
+ // Function to delete the selected image
581
+ const deleteSelectedImage = useCallback(() => {
582
+ if (draggingImage) {
583
+ setUploadedImages(prev => prev.filter(img => img.id !== draggingImage));
584
+ setDraggingImage(null);
585
+ renderCanvas();
586
+ saveCanvasState();
587
+ }
588
+ }, [draggingImage, renderCanvas, saveCanvasState]);
589
+
590
+ // Modify existing startDrawing to check for image interaction first
591
+ const handleStartDrawing = (e) => {
592
+ console.log('Canvas onMouseDown', { currentTool, isDrawing });
593
+
594
+ // Check if we're interacting with an image first
595
+ if (handleImageMouseDown(e)) {
596
+ return;
597
+ }
598
+
599
+ if (currentTool === 'pen') {
600
+ if (!checkForPointOrHandle(e)) {
601
+ handlePenToolClick(e);
602
+ }
603
+ return;
604
+ }
605
+
606
+ const { x, y } = getCoordinates(e, canvasRef.current);
607
+
608
+ if (['rect', 'circle', 'line', 'star'].includes(currentTool)) {
609
+ setShapeStartPos({ x, y });
610
+
611
+ // Create preview canvas if it doesn't exist
612
+ if (!previewCanvas) {
613
+ const canvas = document.createElement('canvas');
614
+ canvas.width = canvasRef.current.width;
615
+ canvas.height = canvasRef.current.height;
616
+ setPreviewCanvas(canvas);
617
+ }
618
+ }
619
+
620
+ startDrawing(e);
621
+ setHasDrawing(true);
622
+ };
623
+
624
+ // Modify existing draw to handle image interaction
625
+ const handleDraw = (e) => {
626
+ // Handle image dragging/resizing first
627
+ if (handleImageMouseMove(e)) {
628
+ return;
629
+ }
630
+
631
+ if (currentTool === 'pen' && handleBezierMouseMove(e)) {
632
+ return;
633
+ }
634
+
635
+ if (!isDrawing) return;
636
+
637
+ const canvas = canvasRef.current;
638
+ const { x, y } = getCoordinates(e, canvas);
639
+
640
+ draw(e);
641
+ };
642
+
643
+ // Modify existing stopDrawing to handle image interaction
644
+ const handleStopDrawing = (e) => {
645
+ // Handle image release first
646
+ if (handleImageMouseUp()) {
647
+ return;
648
+ }
649
+
650
+ console.log('handleStopDrawing called', {
651
+ eventType: e?.type,
652
+ currentTool,
653
+ isDrawing,
654
+ activePoint,
655
+ activeHandle
656
+ });
657
+
658
+ // If we're using the pen tool with active point or handle
659
+ if (currentTool === 'pen') {
660
+ // If we were dragging a handle, just release it
661
+ if (activeHandle) {
662
+ setActiveHandle(null);
663
+ return;
664
+ }
665
+
666
+ // If we were dragging an anchor point, just release it
667
+ if (activePoint !== -1) {
668
+ setActivePoint(-1);
669
+ return;
670
+ }
671
+ }
672
+
673
+ stopDrawing(e);
674
+
675
+ // If using the pencil tool and we've just finished a drag, trigger generation
676
+ if (currentTool === 'pencil' && isDrawing && !isGenerating) {
677
+ console.log(`${currentTool} tool condition met, will try to trigger generation`);
678
+
679
+ // Set generating flag to prevent multiple calls
680
+ if (typeof setIsGenerating === 'function') {
681
+ setIsGenerating(true);
682
+ }
683
+
684
+ // Generate immediately - no timeout needed
685
+ console.log('Calling handleGeneration function');
686
+ if (typeof handleGenerationRef.current === 'function') {
687
+ handleGenerationRef.current();
688
+ } else {
689
+ console.error('handleGeneration is not a function:', handleGenerationRef.current);
690
+ }
691
+ } else {
692
+ console.log('Generation not triggered because:', {
693
+ isPencilTool: currentTool === 'pencil',
694
+ wasDrawing: isDrawing,
695
+ isGenerating
696
+ });
697
+ }
698
+ };
699
+
700
+ // Handle keyboard events for image deletion
701
+ useEffect(() => {
702
+ const handleKeyDown = (e) => {
703
+ if ((e.key === 'Delete' || e.key === 'Backspace') && draggingImage) {
704
+ deleteSelectedImage();
705
+ }
706
+ };
707
+
708
+ window.addEventListener('keydown', handleKeyDown);
709
+ return () => {
710
+ window.removeEventListener('keydown', handleKeyDown);
711
+ };
712
+ }, [draggingImage, deleteSelectedImage]);
713
+
714
+ // Check if we clicked on an existing point or handle
715
+ const checkForPointOrHandle = (e) => {
716
+ if (currentTool !== 'pen' || !showBezierGuides || tempPoints.length === 0) {
717
+ return false;
718
+ }
719
+
720
+ const canvas = canvasRef.current;
721
+ const { x, y } = getCoordinates(e, canvas);
722
+ setLastMousePos({ x, y });
723
+
724
+ // Check if we clicked on a handle
725
+ for (let i = 0; i < tempPoints.length; i++) {
726
+ const point = tempPoints[i];
727
+
728
+ // Check for handleIn
729
+ if (isNearHandle(point, 'handleIn', x, y)) {
730
+ setActivePoint(i);
731
+ setActiveHandle('handleIn');
732
+ return true;
733
+ }
734
+
735
+ // Check for handleOut
736
+ if (isNearHandle(point, 'handleOut', x, y)) {
737
+ setActivePoint(i);
738
+ setActiveHandle('handleOut');
739
+ return true;
740
+ }
741
+
742
+ // Check for the anchor point itself
743
+ const distance = Math.sqrt((point.x - x) ** 2 + (point.y - y) ** 2);
744
+ if (distance <= 10) {
745
+ setActivePoint(i);
746
+ setActiveHandle(null);
747
+ return true;
748
+ }
749
+ }
750
+
751
+ return false;
752
+ };
753
+
754
+ // Handle mouse move for bezier control point or handle dragging
755
+ const handleBezierMouseMove = (e) => {
756
+ if (currentTool !== 'pen') {
757
+ return false;
758
+ }
759
+
760
+ const canvas = canvasRef.current;
761
+ const { x, y } = getCoordinates(e, canvas);
762
+ const dx = x - lastMousePos.x;
763
+ const dy = y - lastMousePos.y;
764
+
765
+ // If we're dragging a handle
766
+ if (activePoint !== -1 && activeHandle) {
767
+ const newPoints = [...tempPoints];
768
+ updateHandle(newPoints[activePoint], activeHandle, dx, dy, symmetric);
769
+ setTempPoints(newPoints);
770
+ setLastMousePos({ x, y });
771
+ return true;
772
+ }
773
+
774
+ // If we're dragging an anchor point
775
+ if (activePoint !== -1) {
776
+ const newPoints = [...tempPoints];
777
+ newPoints[activePoint].x += dx;
778
+ newPoints[activePoint].y += dy;
779
+
780
+ // If this point has handles, move them with the point
781
+ if (newPoints[activePoint].handleIn) {
782
+ // No need to change the handle's offset, just move with the point
783
+ }
784
+
785
+ if (newPoints[activePoint].handleOut) {
786
+ // No need to change the handle's offset, just move with the point
787
+ }
788
+
789
+ setTempPoints(newPoints);
790
+ setLastMousePos({ x, y });
791
+ return true;
792
+ }
793
+
794
+ return false;
795
+ };
796
+
797
+ // Handle clicks for bezier curve tool
798
+ const handlePenToolClick = (e) => {
799
+ const canvas = canvasRef.current;
800
+ const { x, y } = getCoordinates(e, canvas);
801
+
802
+ // Add a new point
803
+ if (tempPoints.length === 0) {
804
+ // First point has no handles initially
805
+ const newPoint = { x, y, handleIn: null, handleOut: null };
806
+ setTempPoints([newPoint]);
807
+ } else {
808
+ // Create a new point with handles relative to the last point
809
+ const newPoint = createAnchorPoint(x, y, tempPoints[tempPoints.length - 1]);
810
+ setTempPoints([...tempPoints, newPoint]);
811
+ }
812
+
813
+ // Always show guides when adding points
814
+ setShowBezierGuides(true);
815
+ };
816
+
817
+ // Toggle bezier guide visibility
818
+ const toggleBezierGuides = () => {
819
+ setShowBezierGuides(!showBezierGuides);
820
+ if (showBezierGuides) {
821
+ redrawBezierGuides();
822
+ }
823
+ };
824
+
825
+ // Draw the final bezier curve and clear control points
826
+ const finalizeBezierCurve = () => {
827
+ if (tempPoints.length < 2) {
828
+ // Need at least 2 points for a path
829
+ console.log('Need at least 2 control points to draw a path');
830
+ return;
831
+ }
832
+
833
+ const canvas = canvasRef.current;
834
+
835
+ // Draw the actual bezier curve
836
+ drawBezierCurve(canvas, tempPoints);
837
+
838
+ // Hide guides and reset control points
839
+ setShowBezierGuides(false);
840
+ setTempPoints([]);
841
+
842
+ // Trigger generation only if not already generating
843
+ if (!isGenerating) {
844
+ // Set generating flag to prevent multiple calls
845
+ if (typeof setIsGenerating === 'function') {
846
+ setIsGenerating(true);
847
+ }
848
+
849
+ if (typeof handleGenerationRef.current === 'function') {
850
+ handleGenerationRef.current();
851
+ }
852
+ }
853
+ };
854
+
855
+ // Add control point to segment
856
+ const addControlPoint = (e) => {
857
+ if (currentTool !== 'pen' || tempPoints.length < 2) return;
858
+
859
+ const canvas = canvasRef.current;
860
+ const { x, y } = getCoordinates(e, canvas);
861
+
862
+ // Find the closest segment to add a point to
863
+ let closestDistance = Number.POSITIVE_INFINITY;
864
+ let insertIndex = -1;
865
+
866
+ for (let i = 0; i < tempPoints.length - 1; i++) {
867
+ const p1 = tempPoints[i];
868
+ const p2 = tempPoints[i + 1];
869
+
870
+ // Calculate distance from click to line between points
871
+ // This is a simplified distance calculation for demo purposes
872
+ const lineLength = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
873
+ if (lineLength === 0) continue;
874
+
875
+ // Project point onto line
876
+ const t = ((x - p1.x) * (p2.x - p1.x) + (y - p1.y) * (p2.y - p1.y)) / (lineLength * lineLength);
877
+
878
+ // If projection is outside the line segment, skip
879
+ if (t < 0 || t > 1) continue;
880
+
881
+ // Calculate closest point on line
882
+ const closestX = p1.x + t * (p2.x - p1.x);
883
+ const closestY = p1.y + t * (p2.y - p1.y);
884
+
885
+ // Calculate distance to closest point
886
+ const distance = Math.sqrt((x - closestX) ** 2 + (y - closestY) ** 2);
887
+
888
+ if (distance < closestDistance && distance < 20) {
889
+ closestDistance = distance;
890
+ insertIndex = i + 1;
891
+ }
892
+ }
893
+
894
+ if (insertIndex > 0) {
895
+ // Create a new array with the new point inserted
896
+ const newPoints = [...tempPoints];
897
+ const prevPoint = newPoints[insertIndex - 1];
898
+ const nextPoint = newPoints[insertIndex];
899
+
900
+ // Create a new point at the click position with automatically calculated handles
901
+ const newPoint = {
902
+ x,
903
+ y,
904
+ // Calculate handles based on the positions of adjacent points
905
+ handleIn: {
906
+ x: (prevPoint.x - x) * 0.25,
907
+ y: (prevPoint.y - y) * 0.25
908
+ },
909
+ handleOut: {
910
+ x: (nextPoint.x - x) * 0.25,
911
+ y: (nextPoint.y - y) * 0.25
912
+ }
913
+ };
914
+
915
+ // Insert the new point
916
+ newPoints.splice(insertIndex, 0, newPoint);
917
+ setTempPoints(newPoints);
918
+ }
919
+ };
920
+
921
+ // Add image compression utility
922
+ const compressImage = async (dataUrl) => {
923
+ return new Promise((resolve, reject) => {
924
+ const img = new Image();
925
+ img.onload = () => {
926
+ const canvas = document.createElement('canvas');
927
+ let width = img.width;
928
+ let height = img.height;
929
+
930
+ // Calculate new dimensions while maintaining aspect ratio
931
+ const MAX_DIMENSION = 1200;
932
+ if (width > height && width > MAX_DIMENSION) {
933
+ height *= MAX_DIMENSION / width;
934
+ width = MAX_DIMENSION;
935
+ } else if (height > MAX_DIMENSION) {
936
+ width *= MAX_DIMENSION / height;
937
+ height = MAX_DIMENSION;
938
+ }
939
+
940
+ canvas.width = width;
941
+ canvas.height = height;
942
+
943
+ const ctx = canvas.getContext('2d');
944
+ ctx.fillStyle = '#FFFFFF';
945
+ ctx.fillRect(0, 0, width, height);
946
+ ctx.drawImage(img, 0, 0, width, height);
947
+
948
+ // Compress as JPEG with 0.8 quality
949
+ resolve(canvas.toDataURL('image/jpeg', 0.8));
950
+ };
951
+ img.onerror = reject;
952
+ img.src = dataUrl;
953
+ });
954
+ };
955
+
956
+ const handleGenerate = () => {
957
+ const canvas = canvasRef.current;
958
+ if (!canvas) return;
959
+
960
+ // Use the ref to ensure we have the latest handler
961
+ if (typeof handleGenerationRef.current === 'function') {
962
+ handleGenerationRef.current();
963
+ }
964
+ };
965
+
966
+ const handleUploadClick = () => {
967
+ fileInputRef.current?.click();
968
+ };
969
+
970
+ // Add custom clearCanvas implementation
971
+ const handleClearCanvas = useCallback(() => {
972
+ const canvas = canvasRef.current;
973
+ const ctx = canvas.getContext('2d');
974
+
975
+ // Clear the canvas with white background
976
+ ctx.fillStyle = '#FFFFFF';
977
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
978
+
979
+ // Reset states
980
+ setTempPoints([]);
981
+ setHasDrawing(false);
982
+ setUploadedImages([]);
983
+
984
+ // Save the cleared state
985
+ saveCanvasState();
986
+
987
+ // Notify about drawing change
988
+ if (typeof onDrawingChange === 'function') {
989
+ onDrawingChange(false);
990
+ }
991
+ }, [saveCanvasState, onDrawingChange]);
992
+
993
+ useImperativeHandle(ref, () => ({
994
+ canvas: canvasRef.current,
995
+ clear: () => clearCanvas(true),
996
+ setHasDrawing: setHasDrawing,
997
+ }), [clearCanvas, setHasDrawing]);
998
+
999
+ return (
1000
+ <div className="flex flex-col gap-4">
1001
+ {/* Canvas container with fixed aspect ratio */}
1002
+ <div
1003
+ ref={canvasContainerRef}
1004
+ className={`relative w-full ${isDraggingFile ? 'bg-gray-100 border-2 border-dashed border-gray-400' : ''}`}
1005
+ style={{ aspectRatio: `${currentDimension.width} / ${currentDimension.height}` }}
1006
+ >
1007
+ <canvas
1008
+ ref={canvasRef}
1009
+ width={currentDimension.width}
1010
+ height={currentDimension.height}
1011
+ className="absolute inset-0 w-full h-full border border-gray-300 bg-white rounded-xl shadow-soft"
1012
+ style={{
1013
+ touchAction: 'none'
1014
+ }}
1015
+ onMouseDown={handleStartDrawing}
1016
+ onMouseMove={handleDraw}
1017
+ onMouseUp={handleStopDrawing}
1018
+ onMouseLeave={handleStopDrawing}
1019
+ onTouchStart={handleStartDrawing}
1020
+ onTouchMove={handleDraw}
1021
+ onTouchEnd={handleStopDrawing}
1022
+ onClick={handleCanvasClick}
1023
+ onKeyDown={handleKeyDown}
1024
+ tabIndex="0"
1025
+ aria-label="Drawing canvas"
1026
+ />
1027
+
1028
+ {/* Floating upload button */}
1029
+ <button
1030
+ type="button"
1031
+ onClick={handleUploadClick}
1032
+ className={`absolute bottom-2.5 right-2.5 z-10 bg-white border border-gray-200 text-gray-600 rounded-lg p-4 sm:p-3 flex items-center justify-center shadow-soft hover:bg-gray-100 transition-colors ${isDrawing ? 'pointer-events-none' : ''}`}
1033
+ aria-label="Upload image"
1034
+ title="Upload image"
1035
+ >
1036
+ <ImagePlus className="w-6 h-6 sm:w-5 sm:h-5" />
1037
+ <input
1038
+ type="file"
1039
+ ref={fileInputRef}
1040
+ onChange={handleFileChange}
1041
+ className="hidden"
1042
+ accept="image/*"
1043
+ />
1044
+ </button>
1045
+
1046
+ {/* Doodle conversion loading overlay */}
1047
+ {isDoodleConverting && (
1048
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-400/80 rounded-xl z-50">
1049
+ <div className="bg-white shadow-lg rounded-xl p-6 flex flex-col items-center">
1050
+ <LoaderCircle className="w-12 h-12 text-gray-700 animate-spin mb-4" />
1051
+ <p className="text-gray-900 font-medium text-lg">Converting to doodle...</p>
1052
+ <p className="text-gray-500 text-sm mt-2">This may take a moment</p>
1053
+ </div>
1054
+ </div>
1055
+ )}
1056
+
1057
+ {/* Sending back to doodle loading overlay */}
1058
+ {isSendingToDoodle && (
1059
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-400/80 rounded-xl z-50">
1060
+ <div className="bg-white shadow-lg rounded-xl p-6 flex flex-col items-center">
1061
+ <LoaderCircle className="w-12 h-12 text-gray-700 animate-spin mb-4" />
1062
+ <p className="text-gray-900 font-medium text-lg">Sending back to doodle...</p>
1063
+ <p className="text-gray-500 text-sm mt-2">Converting and loading...</p>
1064
+ </div>
1065
+ </div>
1066
+ )}
1067
+
1068
+ {/* Draw here placeholder */}
1069
+ {!hasDrawing && !isDoodleConverting && !isSendingToDoodle && (
1070
+ <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
1071
+ <PencilLine className="w-8 h-8 text-gray-400 mb-2" />
1072
+ <p className="text-gray-400 text-lg font-medium">Draw here</p>
1073
+ </div>
1074
+ )}
1075
+
1076
+ {/* Drag and drop indicator */}
1077
+ {isDraggingFile && (
1078
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-100/80 border-2 border-dashed border-gray-400 rounded-xl z-40 pointer-events-none">
1079
+ <ImagePlus className="w-12 h-12 text-gray-500 mb-4" />
1080
+ <p className="text-gray-600 text-lg font-medium">Drop image to convert to doodle</p>
1081
+ </div>
1082
+ )}
1083
+ </div>
1084
+
1085
+ {/* Style selector - positioned below canvas */}
1086
+ <div className="w-full">
1087
+ <StyleSelector
1088
+ styleMode={styleMode}
1089
+ setStyleMode={setStyleMode}
1090
+ handleGenerate={handleGeneration}
1091
+ />
1092
+ </div>
1093
+ </div>
1094
+ );
1095
+ });
1096
+
1097
+ Canvas.displayName = 'Canvas';
1098
+
1099
+ export default Canvas;
components/CanvasContainer.js ADDED
@@ -0,0 +1,1651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import Canvas from "./Canvas";
3
+ import DisplayCanvas from "./DisplayCanvas";
4
+ import ToolBar from "./ToolBar";
5
+ import StyleSelector from "./StyleSelector";
6
+ import { getPromptForStyle, styleOptions, addMaterialToLibrary } from "./StyleSelector";
7
+ import ActionBar from "./ActionBar";
8
+ import ErrorModal from "./ErrorModal";
9
+ import TextInput from "./TextInput";
10
+ import Header from "./Header";
11
+ import DimensionSelector from "./DimensionSelector";
12
+ import HistoryModal from "./HistoryModal";
13
+ import BottomToolBar from "./BottomToolBar";
14
+ import LibraryPage from "./LibraryPage";
15
+ import {
16
+ getCoordinates,
17
+ initializeCanvas,
18
+ drawImageToCanvas,
19
+ drawBezierCurve,
20
+ } from "./utils/canvasUtils";
21
+ import { toast } from "react-hot-toast";
22
+ import { Download, History as HistoryIcon, RefreshCw as RefreshIcon, Library as LibraryIcon, LoaderCircle } from "lucide-react";
23
+ import OutputOptionsBar from "./OutputOptionsBar";
24
+ import ApiKeyModal from "./ApiKeyModal";
25
+ import HeaderButtons from "./HeaderButtons";
26
+
27
+ const CanvasContainer = () => {
28
+ // Check if the device is mobile based on screen width
29
+ const isMobileDevice = () => {
30
+ if (typeof window !== 'undefined') {
31
+ return window.innerWidth < 768; // Common breakpoint for mobile devices
32
+ }
33
+ return false; // Default to desktop on server-side
34
+ };
35
+
36
+ // Get default dimensions based on device type
37
+ const getDefaultDimension = () => {
38
+ if (isMobileDevice()) {
39
+ // Square (1:1) for mobile
40
+ return {
41
+ id: "square",
42
+ label: "1:1",
43
+ width: 1000,
44
+ height: 1000,
45
+ };
46
+ } else {
47
+ // Landscape (3:2) for desktop
48
+ return {
49
+ id: "landscape",
50
+ label: "3:2",
51
+ width: 1500,
52
+ height: 1000,
53
+ };
54
+ }
55
+ };
56
+
57
+ const canvasRef = useRef(null);
58
+ const canvasComponentRef = useRef(null);
59
+ const displayCanvasRef = useRef(null);
60
+ const backgroundImageRef = useRef(null);
61
+ const [currentDimension, setCurrentDimension] = useState(getDefaultDimension());
62
+ const [isDrawing, setIsDrawing] = useState(false);
63
+ const [penColor, setPenColor] = useState("#000000");
64
+ const [penWidth, setPenWidth] = useState(2);
65
+ const colorInputRef = useRef(null);
66
+ const [prompt, setPrompt] = useState("");
67
+ const [generatedImage, setGeneratedImage] = useState(null);
68
+ const [isLoading, setIsLoading] = useState(false);
69
+ const [showErrorModal, setShowErrorModal] = useState(false);
70
+ const [errorMessage, setErrorMessage] = useState("");
71
+ const [customApiKey, setCustomApiKey] = useState("");
72
+ const [styleMode, setStyleMode] = useState("material");
73
+ const [strokeCount, setStrokeCount] = useState(0);
74
+ const strokeTimeoutRef = useRef(null);
75
+ const [lastRequestTime, setLastRequestTime] = useState(0);
76
+ const MIN_REQUEST_INTERVAL = 2000; // Minimum 2 seconds between requests
77
+ const [currentTool, setCurrentTool] = useState("pencil"); // 'pencil', 'pen', 'eraser', 'text', 'rect', 'circle', 'line', 'star'
78
+ const [isTyping, setIsTyping] = useState(false);
79
+ const [undoStack, setUndoStack] = useState([]);
80
+ const [bezierPoints, setBezierPoints] = useState([]);
81
+ const [textInput, setTextInput] = useState("");
82
+ const [textPosition, setTextPosition] = useState({ x: 0, y: 0 });
83
+ const textInputRef = useRef(null);
84
+ const [isPenDrawing, setIsPenDrawing] = useState(false);
85
+ const [currentBezierPath, setCurrentBezierPath] = useState([]);
86
+ const [tempPoints, setTempPoints] = useState([]);
87
+ const [hasGeneratedContent, setHasGeneratedContent] = useState(false);
88
+ const [imageHistory, setImageHistory] = useState([]);
89
+ const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false);
90
+ const [hasDrawing, setHasDrawing] = useState(false);
91
+ // Add a ref to track style changes that need regeneration
92
+ const needsRegenerationRef = useRef(false);
93
+ // Add a ref to track if regeneration was manually triggered
94
+ const isManualRegenerationRef = useRef(false);
95
+ const [isSendingToDoodle, setIsSendingToDoodle] = useState(false);
96
+ // Add state for API key modal
97
+ const [showApiKeyModal, setShowApiKeyModal] = useState(false);
98
+ const [showLibrary, setShowLibrary] = useState(false);
99
+ // Add state for template loading
100
+ const [isTemplateLoading, setIsTemplateLoading] = useState(false);
101
+ const [templateLoadingMessage, setTemplateLoadingMessage] = useState("");
102
+
103
+ // Load saved API key from localStorage on component mount
104
+ useEffect(() => {
105
+ const savedApiKey = localStorage.getItem("geminiApiKey");
106
+ if (savedApiKey) {
107
+ setCustomApiKey(savedApiKey);
108
+ // Validate the API key silently
109
+ validateApiKey(savedApiKey);
110
+ }
111
+ }, []);
112
+
113
+ // Add a function to validate the API key
114
+ const validateApiKey = async (apiKey) => {
115
+ if (!apiKey) return;
116
+
117
+ try {
118
+ const response = await fetch("/api/validate-key", {
119
+ method: "POST",
120
+ headers: {
121
+ "Content-Type": "application/json",
122
+ },
123
+ body: JSON.stringify({ apiKey }),
124
+ });
125
+
126
+ const data = await response.json();
127
+
128
+ if (!data.valid) {
129
+ console.warn("Invalid API key detected, will be cleared");
130
+ // Clear the invalid key
131
+ localStorage.removeItem("geminiApiKey");
132
+ setCustomApiKey("");
133
+ // Don't show error to user for now - they'll see it when trying to use the app
134
+ }
135
+ } catch (error) {
136
+ console.error("Error validating API key:", error);
137
+ // Don't clear the key on connection errors
138
+ }
139
+ };
140
+
141
+ // Load background image when generatedImage changes
142
+ useEffect(() => {
143
+ if (generatedImage && canvasRef.current) {
144
+ // Use the window.Image constructor to avoid conflict with Next.js Image component
145
+ const img = new window.Image();
146
+ img.onload = () => {
147
+ backgroundImageRef.current = img;
148
+ drawImageToCanvas(canvasRef.current, backgroundImageRef.current);
149
+ };
150
+ img.src = generatedImage;
151
+ }
152
+ }, [generatedImage]);
153
+
154
+ // Initialize canvas with white background when component mounts
155
+ useEffect(() => {
156
+ if (canvasRef.current) {
157
+ initializeCanvas(canvasRef.current);
158
+ }
159
+
160
+ // Also initialize the display canvas
161
+ if (displayCanvasRef.current) {
162
+ const displayCtx = displayCanvasRef.current.getContext("2d");
163
+ displayCtx.fillStyle = "#FFFFFF";
164
+ displayCtx.fillRect(
165
+ 0,
166
+ 0,
167
+ displayCanvasRef.current.width,
168
+ displayCanvasRef.current.height
169
+ );
170
+ }
171
+ }, []);
172
+
173
+ // Add resize listener to update dimensions when switching between mobile and desktop
174
+ useEffect(() => {
175
+ let isMobile = isMobileDevice();
176
+
177
+ const handleResize = () => {
178
+ const newIsMobile = isMobileDevice();
179
+ // Only update dimensions if the device type changed (mobile <-> desktop)
180
+ if (newIsMobile !== isMobile) {
181
+ isMobile = newIsMobile;
182
+
183
+ // Only update dimensions if the canvas is empty (no drawing)
184
+ if (canvasRef.current && !hasDrawing && !hasGeneratedContent) {
185
+ setCurrentDimension(getDefaultDimension());
186
+ }
187
+ }
188
+ };
189
+
190
+ window.addEventListener('resize', handleResize);
191
+ return () => window.removeEventListener('resize', handleResize);
192
+ }, [hasDrawing, hasGeneratedContent]);
193
+
194
+ // Add an effect to sync canvas dimensions when they change
195
+ useEffect(() => {
196
+ if (canvasRef.current && displayCanvasRef.current) {
197
+ // Ensure both canvases have the same dimensions
198
+ canvasRef.current.width = currentDimension.width;
199
+ canvasRef.current.height = currentDimension.height;
200
+ displayCanvasRef.current.width = currentDimension.width;
201
+ displayCanvasRef.current.height = currentDimension.height;
202
+
203
+ // Initialize both canvases with white backgrounds
204
+ initializeCanvas(canvasRef.current);
205
+
206
+ const displayCtx = displayCanvasRef.current.getContext("2d");
207
+ displayCtx.fillStyle = "#FFFFFF";
208
+ displayCtx.fillRect(
209
+ 0,
210
+ 0,
211
+ displayCanvasRef.current.width,
212
+ displayCanvasRef.current.height
213
+ );
214
+ }
215
+ }, [currentDimension]);
216
+
217
+ const startDrawing = (e) => {
218
+ const { x, y } = getCoordinates(e, canvasRef.current);
219
+
220
+ if (e.type === "touchstart") {
221
+ e.preventDefault();
222
+ }
223
+
224
+ console.log("startDrawing called", { currentTool, x, y });
225
+
226
+ const ctx = canvasRef.current.getContext("2d");
227
+
228
+ // Set up the line style at the start of drawing
229
+ ctx.lineWidth = currentTool === "eraser" ? 20 : penWidth;
230
+ ctx.lineCap = "round";
231
+ ctx.lineJoin = "round";
232
+ ctx.strokeStyle = currentTool === "eraser" ? "#FFFFFF" : penColor;
233
+
234
+ ctx.beginPath();
235
+ ctx.moveTo(x, y);
236
+ setIsDrawing(true);
237
+ setStrokeCount((prev) => prev + 1);
238
+
239
+ // Save canvas state before drawing
240
+ saveCanvasState();
241
+ };
242
+
243
+ const draw = (e) => {
244
+ if (!isDrawing) return;
245
+
246
+ const canvas = canvasRef.current;
247
+ const ctx = canvas.getContext("2d");
248
+ const { x, y } = getCoordinates(e, canvas);
249
+
250
+ // Occasionally log drawing activity
251
+ if (Math.random() < 0.05) {
252
+ // Only log ~5% of move events to avoid console spam
253
+ console.log("draw called", { currentTool, isDrawing, x, y });
254
+ }
255
+
256
+ // Set up the line style before drawing
257
+ ctx.lineWidth = currentTool === "eraser" ? 60 : penWidth * 4; // Pen width now 4x original size
258
+ ctx.lineCap = "round";
259
+ ctx.lineJoin = "round";
260
+
261
+ if (currentTool === "eraser") {
262
+ ctx.strokeStyle = "#FFFFFF";
263
+ } else {
264
+ ctx.strokeStyle = penColor;
265
+ }
266
+
267
+ if (currentTool === "pen") {
268
+ // Show preview line while moving
269
+ if (tempPoints.length > 0) {
270
+ const lastPoint = tempPoints[tempPoints.length - 1];
271
+ ctx.beginPath();
272
+ ctx.moveTo(lastPoint.x, lastPoint.y);
273
+ ctx.lineTo(x, y);
274
+ ctx.stroke();
275
+ }
276
+ } else {
277
+ ctx.lineTo(x, y);
278
+ ctx.stroke();
279
+ }
280
+ };
281
+
282
+ const stopDrawing = async (e) => {
283
+ console.log("stopDrawing called in CanvasContainer", {
284
+ isDrawing,
285
+ currentTool,
286
+ hasEvent: !!e,
287
+ eventType: e ? e.type : "none",
288
+ });
289
+
290
+ if (!isDrawing) return;
291
+ setIsDrawing(false);
292
+
293
+ // Remove the timeout-based generation
294
+ if (strokeTimeoutRef.current) {
295
+ clearTimeout(strokeTimeoutRef.current);
296
+ strokeTimeoutRef.current = null;
297
+ }
298
+
299
+ // The Canvas component will handle generation for pen and pencil tools directly
300
+ // This function now primarily handles stroke counting for other tools
301
+
302
+ // Only generate on mouse/touch up events when not using the pen or pencil tool
303
+ // (since those are handled by the Canvas component)
304
+ if (
305
+ e &&
306
+ (e.type === "mouseup" || e.type === "touchend") &&
307
+ currentTool !== "pen" &&
308
+ currentTool !== "pencil"
309
+ ) {
310
+ console.log("stopDrawing: detected mouseup/touchend event", {
311
+ strokeCount,
312
+ });
313
+ // Check if we have enough strokes to generate (increased to 10 from 3)
314
+ if (strokeCount >= 10) {
315
+ console.log(
316
+ "stopDrawing: calling handleGeneration due to stroke count"
317
+ );
318
+ await handleGeneration();
319
+ setStrokeCount(0);
320
+ }
321
+ }
322
+ };
323
+
324
+ const clearCanvas = () => {
325
+ // If we have a ref to our Canvas component, use its custom clear method
326
+ if (canvasComponentRef.current?.handleClearCanvas) {
327
+ canvasComponentRef.current.handleClearCanvas();
328
+ return;
329
+ }
330
+
331
+ // Fallback to original implementation
332
+ const canvas = canvasRef.current;
333
+ if (!canvas) return;
334
+
335
+ initializeCanvas(canvas);
336
+
337
+ setGeneratedImage(null);
338
+ backgroundImageRef.current = null;
339
+
340
+ // Also clear the display canvas and reset generated content flag
341
+ if (displayCanvasRef.current) {
342
+ const displayCtx = displayCanvasRef.current.getContext("2d");
343
+ displayCtx.clearRect(
344
+ 0,
345
+ 0,
346
+ displayCanvasRef.current.width,
347
+ displayCanvasRef.current.height
348
+ );
349
+ displayCtx.fillStyle = "#FFFFFF";
350
+ displayCtx.fillRect(
351
+ 0,
352
+ 0,
353
+ displayCanvasRef.current.width,
354
+ displayCanvasRef.current.height
355
+ );
356
+ setHasGeneratedContent(false);
357
+ }
358
+
359
+ // Save empty canvas state
360
+ saveCanvasState();
361
+ };
362
+
363
+ const handleGeneration = useCallback(
364
+ async (isManualRegeneration = false) => {
365
+ console.log("handleGeneration called", { isManualRegeneration });
366
+
367
+ // Set our ref if this is a manual regeneration
368
+ if (isManualRegeneration) {
369
+ isManualRegenerationRef.current = true;
370
+ }
371
+
372
+ // Remove the time throttling for automatic generation after doodle conversion
373
+ // but keep it for manual generations
374
+ const isAutoGeneration = !lastRequestTime && !isManualRegeneration;
375
+ if (!isAutoGeneration) {
376
+ const now = Date.now();
377
+ if (now - lastRequestTime < MIN_REQUEST_INTERVAL) {
378
+ console.log("Request throttled - too soon after last request");
379
+ return;
380
+ }
381
+ setLastRequestTime(now);
382
+ }
383
+
384
+ if (!canvasRef.current) return;
385
+
386
+ console.log("Starting generation process");
387
+
388
+ // Check if we're already in a loading state before setting it
389
+ if (!isLoading) {
390
+ setIsLoading(true);
391
+ }
392
+
393
+ try {
394
+ const canvas = canvasRef.current;
395
+ const tempCanvas = document.createElement("canvas");
396
+ tempCanvas.width = canvas.width;
397
+ tempCanvas.height = canvas.height;
398
+ const tempCtx = tempCanvas.getContext("2d");
399
+
400
+ tempCtx.fillStyle = "#FFFFFF";
401
+ tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
402
+ tempCtx.drawImage(canvas, 0, 0);
403
+
404
+ const drawingData = tempCanvas.toDataURL("image/png").split(",")[1];
405
+
406
+ const materialPrompt = getPromptForStyle(styleMode);
407
+
408
+ const requestPayload = {
409
+ prompt: materialPrompt,
410
+ drawingData,
411
+ customApiKey,
412
+ };
413
+
414
+ console.log("Making API request with style:", styleMode);
415
+ console.log(`Using prompt: ${materialPrompt.substring(0, 100)}...`);
416
+
417
+ const response = await fetch("/api/generate", {
418
+ method: "POST",
419
+ headers: {
420
+ "Content-Type": "application/json",
421
+ },
422
+ body: JSON.stringify(requestPayload),
423
+ });
424
+
425
+ console.log("API response received, status:", response.status);
426
+
427
+ const data = await response.json();
428
+
429
+ if (data.success && data.imageData) {
430
+ console.log("Image generated successfully");
431
+ const imageUrl = `data:image/png;base64,${data.imageData}`;
432
+
433
+ // Draw the generated image to the display canvas
434
+ const displayCanvas = displayCanvasRef.current;
435
+ if (!displayCanvas) {
436
+ console.error("Display canvas ref is null");
437
+ return;
438
+ }
439
+
440
+ const displayCtx = displayCanvas.getContext("2d");
441
+
442
+ // Clear the display canvas first
443
+ displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height);
444
+ displayCtx.fillStyle = "#FFFFFF";
445
+ displayCtx.fillRect(0, 0, displayCanvas.width, displayCanvas.height);
446
+
447
+ // Create and load the new image
448
+ const img = new Image();
449
+
450
+ // Set up the onload handler before setting the src
451
+ img.onload = () => {
452
+ console.log("Generated image loaded, drawing to display canvas");
453
+
454
+ // Clear the canvas first
455
+ displayCtx.clearRect(
456
+ 0,
457
+ 0,
458
+ displayCanvas.width,
459
+ displayCanvas.height
460
+ );
461
+
462
+ // Fill with black background for letterboxing
463
+ displayCtx.fillStyle = "#000000";
464
+ displayCtx.fillRect(
465
+ 0,
466
+ 0,
467
+ displayCanvas.width,
468
+ displayCanvas.height
469
+ );
470
+
471
+ // Calculate aspect ratios
472
+ const imgRatio = img.width / img.height;
473
+ const canvasRatio = displayCanvas.width / displayCanvas.height;
474
+
475
+ let drawWidth, drawHeight, x, y;
476
+
477
+ if (imgRatio > canvasRatio) {
478
+ // Image is wider than canvas (relative to height)
479
+ drawWidth = displayCanvas.width;
480
+ drawHeight = displayCanvas.width / imgRatio;
481
+ x = 0;
482
+ y = (displayCanvas.height - drawHeight) / 2;
483
+ } else {
484
+ // Image is taller than canvas (relative to width)
485
+ drawHeight = displayCanvas.height;
486
+ drawWidth = displayCanvas.height * imgRatio;
487
+ x = (displayCanvas.width - drawWidth) / 2;
488
+ y = 0;
489
+ }
490
+
491
+ // Draw the image with letterboxing
492
+ displayCtx.drawImage(img, x, y, drawWidth, drawHeight);
493
+
494
+ // Update our state to indicate we have generated content
495
+ setHasGeneratedContent(true);
496
+
497
+ // Add to history
498
+ setImageHistory((prev) => [
499
+ ...prev,
500
+ {
501
+ imageUrl,
502
+ timestamp: Date.now(),
503
+ drawingData: canvas.toDataURL(),
504
+ styleMode,
505
+ dimensions: currentDimension,
506
+ },
507
+ ]);
508
+ };
509
+
510
+ // Set the src to trigger loading
511
+ img.src = imageUrl;
512
+ } else {
513
+ console.error("Failed to generate image:", data.error);
514
+
515
+ // When generation fails, ensure display canvas is cleared
516
+ if (displayCanvasRef.current) {
517
+ const displayCtx = displayCanvasRef.current.getContext("2d");
518
+ displayCtx.clearRect(
519
+ 0,
520
+ 0,
521
+ displayCanvasRef.current.width,
522
+ displayCanvasRef.current.height
523
+ );
524
+ displayCtx.fillStyle = "#FFFFFF";
525
+ displayCtx.fillRect(
526
+ 0,
527
+ 0,
528
+ displayCanvasRef.current.width,
529
+ displayCanvasRef.current.height
530
+ );
531
+ }
532
+
533
+ // Make sure we mark that we don't have generated content
534
+ setHasGeneratedContent(false);
535
+
536
+ // Check for quota or API key errors
537
+ if (
538
+ data.error &&
539
+ (data.error.includes("Resource has been exhausted") ||
540
+ data.error.includes("quota") ||
541
+ data.error.includes("exceeded") ||
542
+ response.status === 429)
543
+ ) {
544
+ // Show API key modal instead of error modal for quota issues
545
+ setShowApiKeyModal(true);
546
+ } else if (response.status === 500) {
547
+ // Show regular error modal for other server errors
548
+ setErrorMessage(data.error);
549
+ setShowErrorModal(true);
550
+ }
551
+ }
552
+ } catch (error) {
553
+ console.error("Error generating image:", error);
554
+
555
+ // Check for quota-related errors in the catch block too
556
+ if (
557
+ error.message &&
558
+ (error.message.includes("Resource has been exhausted") ||
559
+ error.message.includes("quota") ||
560
+ error.message.includes("exceeded") ||
561
+ error.message.includes("429"))
562
+ ) {
563
+ // Show API key modal for quota issues
564
+ setShowApiKeyModal(true);
565
+ } else {
566
+ // Show regular error modal for other errors
567
+ setErrorMessage(error.message || "An unexpected error occurred.");
568
+ setShowErrorModal(true);
569
+ }
570
+
571
+ // When generation errors, ensure display canvas is cleared
572
+ if (displayCanvasRef.current) {
573
+ const displayCtx = displayCanvasRef.current.getContext("2d");
574
+ displayCtx.clearRect(
575
+ 0,
576
+ 0,
577
+ displayCanvasRef.current.width,
578
+ displayCanvasRef.current.height
579
+ );
580
+ displayCtx.fillStyle = "#FFFFFF";
581
+ displayCtx.fillRect(
582
+ 0,
583
+ 0,
584
+ displayCanvasRef.current.width,
585
+ displayCanvasRef.current.height
586
+ );
587
+ }
588
+
589
+ // Make sure we mark that we don't have generated content
590
+ setHasGeneratedContent(false);
591
+ } finally {
592
+ setIsLoading(false);
593
+ console.log("Generation process completed");
594
+ }
595
+ },
596
+ [canvasRef, isLoading, styleMode, customApiKey, lastRequestTime]
597
+ );
598
+
599
+ // Close the error modal
600
+ const closeErrorModal = () => {
601
+ setShowErrorModal(false);
602
+ };
603
+
604
+ // Handle the custom API key submission
605
+ const handleApiKeySubmit = (apiKey) => {
606
+ setCustomApiKey(apiKey);
607
+ // Save to localStorage for persistence
608
+ localStorage.setItem("geminiApiKey", apiKey);
609
+ // Close the API key modal
610
+ setShowApiKeyModal(false);
611
+ // Also close error modal if it was open
612
+ setShowErrorModal(false);
613
+ // Show confirmation toast
614
+ toast.success("API key saved successfully");
615
+ };
616
+
617
+ // Add this function to handle undo
618
+ const handleUndo = () => {
619
+ if (undoStack.length > 0) {
620
+ const canvas = canvasRef.current;
621
+ const ctx = canvas.getContext("2d");
622
+ const previousState = undoStack[undoStack.length - 2]; // Get second to last state
623
+
624
+ if (previousState) {
625
+ const img = new Image();
626
+ img.onload = () => {
627
+ ctx.fillStyle = "#FFFFFF";
628
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
629
+ ctx.drawImage(img, 0, 0);
630
+ };
631
+ img.src = previousState;
632
+ } else {
633
+ // If no previous state, clear to white
634
+ ctx.fillStyle = "#FFFFFF";
635
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
636
+ }
637
+
638
+ setUndoStack((prev) => prev.slice(0, -1));
639
+ }
640
+ };
641
+
642
+ // Add this function to save canvas state
643
+ const saveCanvasState = () => {
644
+ const canvas = canvasRef.current;
645
+ if (!canvas) return;
646
+
647
+ const dataURL = canvas.toDataURL();
648
+ setUndoStack((prev) => [...prev, dataURL]);
649
+ };
650
+
651
+ // Add this function to handle text input
652
+ const handleTextInput = (e) => {
653
+ if (e.key === "Enter") {
654
+ const canvas = canvasRef.current;
655
+ const ctx = canvas.getContext("2d");
656
+ ctx.font = "24px Arial";
657
+ ctx.fillStyle = "#000000";
658
+ ctx.fillText(textInput, textPosition.x, textPosition.y);
659
+ setTextInput("");
660
+ setIsTyping(false);
661
+ saveCanvasState();
662
+ }
663
+ };
664
+
665
+ // Modify the canvas click handler to handle text placement
666
+ const handleCanvasClick = (e) => {
667
+ if (currentTool === "text") {
668
+ const { x, y } = getCoordinates(e, canvasRef.current);
669
+ setTextPosition({ x, y });
670
+ setIsTyping(true);
671
+ if (textInputRef.current) {
672
+ textInputRef.current.focus();
673
+ }
674
+ }
675
+ };
676
+
677
+ // Handle pen click for bezier curve tool
678
+ const handlePenClick = (e) => {
679
+ if (currentTool !== "pen") return;
680
+
681
+ // Note: Actual point creation is now handled in the Canvas component
682
+ // This function is primarily used as a callback to inform the CanvasContainer
683
+ // that a pen action happened
684
+
685
+ console.log("handlePenClick called in CanvasContainer");
686
+
687
+ // Set isDrawing flag to true when using pen tool
688
+ // This ensures handleStopDrawing knows we're in drawing mode with the pen
689
+ setIsDrawing(true);
690
+
691
+ // Save canvas state when adding new points
692
+ saveCanvasState();
693
+ };
694
+
695
+ // Add this new function near your other utility functions
696
+ const handleSaveImage = useCallback(() => {
697
+ if (displayCanvasRef.current && hasGeneratedContent) {
698
+ const canvas = displayCanvasRef.current;
699
+ const link = document.createElement('a');
700
+
701
+ // Create timestamp in format: YYYYMMDD_HHMM
702
+ const now = new Date();
703
+ const timestamp = now.toISOString()
704
+ .replace(/[-:T]/g, '') // Remove all separators
705
+ .slice(0, 12); // Keep only YYYYMMDDHHMM
706
+
707
+ // Get the actual material name from styleOptions
708
+ const materialName = styleOptions[styleMode]?.name || styleMode;
709
+
710
+ // Create filename: timestamp_materialname.png
711
+ const filename = `${timestamp}_${materialName}.png`;
712
+
713
+ link.download = filename;
714
+ link.href = canvas.toDataURL('image/png');
715
+ link.click();
716
+ toast.success(`Saved as "${filename}"`);
717
+ } else {
718
+ toast.error("No generated image to save.");
719
+ }
720
+ }, [displayCanvasRef, hasGeneratedContent, styleMode]);
721
+
722
+ // Add this function to handle regeneration
723
+ const handleRegenerate = async () => {
724
+ if (canvasRef.current) {
725
+ // Set flag to prevent useEffect hooks from triggering additional generations
726
+ isManualRegenerationRef.current = true;
727
+ await handleGeneration(true);
728
+ }
729
+ };
730
+
731
+ // Add useEffect to watch for styleMode changes and regenerate
732
+ // eslint-disable-next-line react-hooks/exhaustive-deps
733
+ useEffect(() => {
734
+ // Skip if this was triggered by a manual regeneration
735
+ if (isManualRegenerationRef.current) {
736
+ console.log("Skipping automatic generation due to manual regeneration");
737
+ return;
738
+ }
739
+
740
+ // Only trigger if we have something drawn (check if canvas is not empty)
741
+ // Note: handleGeneration is intentionally omitted from dependencies to prevent infinite loops
742
+ const checkCanvasAndGenerate = async () => {
743
+ if (!canvasRef.current) return;
744
+
745
+ const canvas = canvasRef.current;
746
+ const ctx = canvas.getContext("2d");
747
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
748
+
749
+ // Check if canvas has any non-white pixels
750
+ const hasDrawing = Array.from(imageData.data).some((pixel, index) => {
751
+ // Check only RGB values (skip alpha)
752
+ return index % 4 !== 3 && pixel !== 255;
753
+ });
754
+
755
+ // Only generate if there's a drawing AND we don't already have generated content
756
+ if (hasDrawing && !hasGeneratedContent) {
757
+ await handleGeneration();
758
+ } else if (hasDrawing) {
759
+ // Mark that regeneration is needed when style changes but we already have content
760
+ needsRegenerationRef.current = true;
761
+ }
762
+ };
763
+
764
+ // Skip on first render
765
+ if (styleMode) {
766
+ checkCanvasAndGenerate();
767
+ }
768
+ }, [styleMode, hasGeneratedContent]); // Removed handleGeneration from dependencies to prevent loop
769
+
770
+ // Add new useEffect to handle regeneration when hasGeneratedContent changes to false
771
+ // eslint-disable-next-line react-hooks/exhaustive-deps
772
+ useEffect(() => {
773
+ // Skip if this was triggered by a manual regeneration
774
+ if (isManualRegenerationRef.current) {
775
+ console.log("Skipping automatic generation due to manual regeneration");
776
+ // Reset the flag after the first render with it set
777
+ isManualRegenerationRef.current = false;
778
+ return;
779
+ }
780
+
781
+ // Note: handleGeneration is intentionally omitted from dependencies to prevent infinite loops
782
+ // If we need regeneration and the generated content was cleared
783
+ if (needsRegenerationRef.current && !hasGeneratedContent) {
784
+ const checkDrawingAndRegenerate = async () => {
785
+ if (!canvasRef.current) return;
786
+
787
+ const canvas = canvasRef.current;
788
+ const ctx = canvas.getContext("2d");
789
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
790
+
791
+ // Check if canvas has any non-white pixels
792
+ const hasDrawing = Array.from(imageData.data).some((pixel, index) => {
793
+ // Check only RGB values (skip alpha)
794
+ return index % 4 !== 3 && pixel !== 255;
795
+ });
796
+
797
+ if (hasDrawing) {
798
+ needsRegenerationRef.current = false;
799
+ await handleGeneration();
800
+ }
801
+ };
802
+
803
+ checkDrawingAndRegenerate();
804
+ }
805
+ }, [hasGeneratedContent]);
806
+
807
+ // Cleanup function - keep this to prevent memory leaks
808
+ useEffect(() => {
809
+ return () => {
810
+ if (strokeTimeoutRef.current) {
811
+ clearTimeout(strokeTimeoutRef.current);
812
+ strokeTimeoutRef.current = null;
813
+ }
814
+ };
815
+ }, []);
816
+
817
+ // Handle dimension change
818
+ const handleDimensionChange = (newDimension) => {
819
+ console.log("Changing dimensions to:", newDimension);
820
+
821
+ // Clear both canvases
822
+ if (canvasRef.current) {
823
+ const canvas = canvasRef.current;
824
+ canvas.width = newDimension.width;
825
+ canvas.height = newDimension.height;
826
+ initializeCanvas(canvas);
827
+ }
828
+
829
+ if (displayCanvasRef.current) {
830
+ const displayCanvas = displayCanvasRef.current;
831
+ displayCanvas.width = newDimension.width;
832
+ displayCanvas.height = newDimension.height;
833
+ const ctx = displayCanvas.getContext("2d");
834
+ ctx.fillStyle = "#FFFFFF";
835
+ ctx.fillRect(0, 0, displayCanvas.width, displayCanvas.height);
836
+ }
837
+
838
+ // Reset generation state
839
+ setHasGeneratedContent(false);
840
+ setGeneratedImage(null);
841
+ backgroundImageRef.current = null;
842
+
843
+ // Update dimension state AFTER canvas dimensions are updated
844
+ setCurrentDimension(newDimension);
845
+ };
846
+
847
+ // Add new function to handle selecting a historical image
848
+ const handleSelectHistoricalImage = (historyItem) => {
849
+ // First set the dimensions and wait for canvases to update
850
+ if (historyItem.dimensions) {
851
+ // Update canvas dimensions first
852
+ if (canvasRef.current) {
853
+ canvasRef.current.width = historyItem.dimensions.width;
854
+ canvasRef.current.height = historyItem.dimensions.height;
855
+ }
856
+ if (displayCanvasRef.current) {
857
+ displayCanvasRef.current.width = historyItem.dimensions.width;
858
+ displayCanvasRef.current.height = historyItem.dimensions.height;
859
+ }
860
+ // Then update the dimension state
861
+ setCurrentDimension(historyItem.dimensions);
862
+ }
863
+
864
+ // Use Promise to ensure images are loaded after dimensions are set
865
+ Promise.resolve().then(() => {
866
+ // Draw the original drawing to the canvas
867
+ const drawingImg = new Image();
868
+ drawingImg.onload = () => {
869
+ const canvas = canvasRef.current;
870
+ if (canvas) {
871
+ const ctx = canvas.getContext("2d");
872
+ ctx.fillStyle = "#FFFFFF";
873
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
874
+ ctx.drawImage(drawingImg, 0, 0, canvas.width, canvas.height);
875
+ }
876
+ };
877
+ drawingImg.src = historyItem.drawingData;
878
+
879
+ // Draw the generated image to the display canvas
880
+ const generatedImg = new Image();
881
+ generatedImg.onload = () => {
882
+ const displayCanvas = displayCanvasRef.current;
883
+ if (displayCanvas) {
884
+ const ctx = displayCanvas.getContext("2d");
885
+ ctx.fillStyle = "#000000"; // Black background for letterboxing
886
+ ctx.fillRect(0, 0, displayCanvas.width, displayCanvas.height);
887
+
888
+ // Calculate aspect ratios
889
+ const imgRatio = generatedImg.width / generatedImg.height;
890
+ const canvasRatio = displayCanvas.width / displayCanvas.height;
891
+
892
+ let drawWidth, drawHeight, x, y;
893
+
894
+ if (imgRatio > canvasRatio) {
895
+ // Image is wider than canvas
896
+ drawWidth = displayCanvas.width;
897
+ drawHeight = displayCanvas.width / imgRatio;
898
+ x = 0;
899
+ y = (displayCanvas.height - drawHeight) / 2;
900
+ } else {
901
+ // Image is taller than canvas
902
+ drawHeight = displayCanvas.height;
903
+ drawWidth = displayCanvas.height * imgRatio;
904
+ x = (displayCanvas.width - drawWidth) / 2;
905
+ y = 0;
906
+ }
907
+
908
+ // Draw the image with letterboxing
909
+ ctx.drawImage(generatedImg, x, y, drawWidth, drawHeight);
910
+ setHasGeneratedContent(true);
911
+ }
912
+ };
913
+ generatedImg.src = historyItem.imageUrl;
914
+ });
915
+
916
+ // Close the history modal
917
+ setIsHistoryModalOpen(false);
918
+ };
919
+
920
+ // Add new function to handle image refinement
921
+ const handleImageRefinement = async (refinementPrompt) => {
922
+ if (!displayCanvasRef.current || !hasGeneratedContent) return;
923
+
924
+ console.log("Starting image refinement with prompt:", refinementPrompt);
925
+ setIsLoading(true);
926
+
927
+ try {
928
+ // Get the current image data
929
+ const displayCanvas = displayCanvasRef.current;
930
+ const imageData = displayCanvas.toDataURL("image/png").split(",")[1];
931
+
932
+ const requestPayload = {
933
+ prompt: refinementPrompt,
934
+ imageData,
935
+ customApiKey,
936
+ };
937
+
938
+ console.log("Making refinement API request");
939
+
940
+ const response = await fetch("/api/refine", {
941
+ method: "POST",
942
+ headers: {
943
+ "Content-Type": "application/json",
944
+ },
945
+ body: JSON.stringify(requestPayload),
946
+ });
947
+
948
+ console.log("Refinement API response received, status:", response.status);
949
+
950
+ const data = await response.json();
951
+
952
+ if (data.success && data.imageData) {
953
+ console.log("Image refined successfully");
954
+ const imageUrl = `data:image/png;base64,${data.imageData}`;
955
+
956
+ // Draw the refined image to the display canvas
957
+ const displayCtx = displayCanvas.getContext("2d");
958
+ const img = new Image();
959
+
960
+ img.onload = () => {
961
+ console.log("Refined image loaded, drawing to display canvas");
962
+
963
+ // Clear the canvas
964
+ displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height);
965
+
966
+ // Fill with black background for letterboxing
967
+ displayCtx.fillStyle = "#000000";
968
+ displayCtx.fillRect(0, 0, displayCanvas.width, displayCanvas.height);
969
+
970
+ // Calculate aspect ratios
971
+ const imgRatio = img.width / img.height;
972
+ const canvasRatio = displayCanvas.width / displayCanvas.height;
973
+
974
+ let drawWidth, drawHeight, x, y;
975
+
976
+ if (imgRatio > canvasRatio) {
977
+ // Image is wider than canvas (relative to height)
978
+ drawWidth = displayCanvas.width;
979
+ drawHeight = displayCanvas.width / imgRatio;
980
+ x = 0;
981
+ y = (displayCanvas.height - drawHeight) / 2;
982
+ } else {
983
+ // Image is taller than canvas (relative to width)
984
+ drawHeight = displayCanvas.height;
985
+ drawWidth = displayCanvas.height * imgRatio;
986
+ x = (displayCanvas.width - drawWidth) / 2;
987
+ y = 0;
988
+ }
989
+
990
+ // Draw the image with letterboxing
991
+ displayCtx.drawImage(img, x, y, drawWidth, drawHeight);
992
+
993
+ // Add to history
994
+ setImageHistory((prev) => [
995
+ ...prev,
996
+ {
997
+ imageUrl,
998
+ timestamp: Date.now(),
999
+ drawingData: canvasRef.current.toDataURL(),
1000
+ styleMode,
1001
+ dimensions: currentDimension,
1002
+ },
1003
+ ]);
1004
+ };
1005
+
1006
+ img.src = imageUrl;
1007
+ } else {
1008
+ console.error("Failed to refine image:", data.error);
1009
+
1010
+ // Check for quota or API key errors
1011
+ if (
1012
+ data.error &&
1013
+ (data.error.includes("Resource has been exhausted") ||
1014
+ data.error.includes("quota") ||
1015
+ data.error.includes("exceeded") ||
1016
+ response.status === 429)
1017
+ ) {
1018
+ // Show API key modal instead of error modal for quota issues
1019
+ setShowApiKeyModal(true);
1020
+ } else {
1021
+ // Show regular error modal for other errors
1022
+ setErrorMessage(data.error || "Failed to refine image. Please try again.");
1023
+ setShowErrorModal(true);
1024
+ }
1025
+ }
1026
+ } catch (error) {
1027
+ console.error("Error during refinement:", error);
1028
+
1029
+ // Check for quota-related errors in the catch block
1030
+ if (
1031
+ error.message &&
1032
+ (error.message.includes("Resource has been exhausted") ||
1033
+ error.message.includes("quota") ||
1034
+ error.message.includes("exceeded") ||
1035
+ error.message.includes("429"))
1036
+ ) {
1037
+ // Show API key modal for quota issues
1038
+ setShowApiKeyModal(true);
1039
+ } else {
1040
+ // Show regular error modal for other errors
1041
+ setErrorMessage("An error occurred during refinement. Please try again.");
1042
+ setShowErrorModal(true);
1043
+ }
1044
+ } finally {
1045
+ setIsLoading(false);
1046
+ }
1047
+ };
1048
+
1049
+ // Add onImageUpload function
1050
+ const handleImageUpload = (imageDataUrl) => {
1051
+ if (!canvasRef.current) return;
1052
+
1053
+ const canvas = canvasRef.current;
1054
+ const ctx = canvas.getContext("2d");
1055
+ const img = new Image();
1056
+
1057
+ img.onload = () => {
1058
+ // Clear the canvas
1059
+ ctx.fillStyle = "#FFFFFF";
1060
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1061
+
1062
+ // Calculate dimensions to maintain aspect ratio and fit within canvas
1063
+ const scale = Math.min(
1064
+ canvas.width / img.width,
1065
+ canvas.height / img.height
1066
+ );
1067
+ const x = (canvas.width - img.width * scale) / 2;
1068
+ const y = (canvas.height - img.height * scale) / 2;
1069
+
1070
+ // Draw the image centered and scaled
1071
+ ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
1072
+
1073
+ // Save canvas state after uploading image
1074
+ saveCanvasState();
1075
+ setHasGeneratedContent(true);
1076
+ };
1077
+
1078
+ img.src = imageDataUrl;
1079
+ };
1080
+
1081
+ // Add stroke width handler
1082
+ const handleStrokeWidth = (width) => {
1083
+ setPenWidth(width);
1084
+ };
1085
+
1086
+ // Function to handle sending the generated image back to the doodle canvas
1087
+ const handleSendToDoodle = useCallback(
1088
+ async (imageDataUrl) => {
1089
+ if (!imageDataUrl || isSendingToDoodle) return;
1090
+
1091
+ console.log("Sending image back to doodle canvas...");
1092
+ setIsSendingToDoodle(true);
1093
+
1094
+ let response; // Define response outside try
1095
+ try {
1096
+ const base64Data = imageDataUrl.split(",")[1];
1097
+
1098
+ response = await fetch("/api/convert-to-doodle", {
1099
+ // Assign to outer scope variable
1100
+ method: "POST",
1101
+ headers: {
1102
+ "Content-Type": "application/json",
1103
+ },
1104
+ body: JSON.stringify({
1105
+ imageData: base64Data,
1106
+ customApiKey // Pass the custom API key
1107
+ }),
1108
+ });
1109
+
1110
+ // Check for non-OK HTTP status first
1111
+ if (!response.ok) {
1112
+ let errorBody = await response.text(); // Get raw text first
1113
+ let errorMessage = `API Error: ${response.status}`;
1114
+ try {
1115
+ // Try parsing error response as JSON
1116
+ const errorData = JSON.parse(errorBody);
1117
+ errorMessage = errorData.error || errorMessage;
1118
+ } catch (parseError) {
1119
+ // If response wasn't JSON (like the "Body exceeded" error)
1120
+ console.error("API response was not valid JSON:", errorBody);
1121
+ // Use truncated raw text in the error message
1122
+ errorMessage = `${errorMessage}. Response: ${errorBody.substring(
1123
+ 0,
1124
+ 100
1125
+ )}${errorBody.length > 100 ? "..." : ""}`;
1126
+ }
1127
+
1128
+ // Check if this is a quota error
1129
+ if (
1130
+ errorMessage.includes("quota") ||
1131
+ errorMessage.includes("exceeded") ||
1132
+ errorMessage.includes("Resource has been exhausted") ||
1133
+ response.status === 429
1134
+ ) {
1135
+ // Show API key modal for quota issues
1136
+ setShowApiKeyModal(true);
1137
+ setIsSendingToDoodle(false);
1138
+ return;
1139
+ }
1140
+
1141
+ throw new Error(errorMessage); // Throw error to be caught below
1142
+ }
1143
+
1144
+ // If response.ok, proceed to parse the JSON body
1145
+ const result = await response.json();
1146
+
1147
+ if (result.success && result.imageData) {
1148
+ const mainCtx = canvasRef.current?.getContext("2d");
1149
+ if (mainCtx && canvasRef.current) {
1150
+ const img = new Image();
1151
+ img.onload = () => {
1152
+ // Clear canvas without triggering state updates
1153
+ mainCtx.fillStyle = '#FFFFFF';
1154
+ mainCtx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
1155
+
1156
+ // Draw the new image
1157
+ mainCtx.drawImage(
1158
+ img,
1159
+ 0,
1160
+ 0,
1161
+ canvasRef.current.width,
1162
+ canvasRef.current.height
1163
+ );
1164
+
1165
+ // Batch our state updates
1166
+ Promise.resolve().then(() => {
1167
+ setTempPoints([]);
1168
+ if (canvasRef.current.setHasDrawing) {
1169
+ canvasRef.current.setHasDrawing(true);
1170
+ }
1171
+ // Save canvas state after all state updates are complete
1172
+ requestAnimationFrame(() => {
1173
+ saveCanvasState();
1174
+ toast.success("Image sent back to doodle canvas!");
1175
+ setIsSendingToDoodle(false);
1176
+ });
1177
+ });
1178
+ };
1179
+ img.onerror = (err) => {
1180
+ console.error("Error loading converted doodle image:", err);
1181
+ toast.error("Failed to load the converted doodle.");
1182
+ setIsSendingToDoodle(false); // Turn off loading on image load error
1183
+ };
1184
+ img.src = `data:image/png;base64,${result.imageData}`;
1185
+ } else {
1186
+ throw new Error("Main canvas context not available.");
1187
+ }
1188
+ } else {
1189
+ // Handle cases where API returns success: false or missing imageData
1190
+ throw new Error(
1191
+ result.error || "API returned success:false or missing data."
1192
+ );
1193
+ }
1194
+ } catch (error) {
1195
+ // This catches errors from fetch, response.ok check, response.json(), or explicit throws
1196
+ console.error("Error sending image back to doodle:", error);
1197
+
1198
+ // Check for quota errors in catch block
1199
+ if (
1200
+ error.message &&
1201
+ (error.message.includes("quota") ||
1202
+ error.message.includes("exceeded") ||
1203
+ error.message.includes("Resource has been exhausted") ||
1204
+ error.message.includes("429"))
1205
+ ) {
1206
+ // Show API key modal for quota issues
1207
+ setShowApiKeyModal(true);
1208
+ } else {
1209
+ toast.error(`Error: ${error.message || "An unknown error occurred."}`);
1210
+ }
1211
+
1212
+ // Ensure loading state is turned off in *any* error scenario
1213
+ setIsSendingToDoodle(false);
1214
+ }
1215
+ },
1216
+ [isSendingToDoodle, clearCanvas, saveCanvasState, setTempPoints, toast, customApiKey]
1217
+ );
1218
+
1219
+ // Function to open history modal
1220
+ const openHistoryModal = () => {
1221
+ setIsHistoryModalOpen(true);
1222
+ };
1223
+
1224
+ // Updated function for library button
1225
+ const toggleLibrary = () => {
1226
+ setShowLibrary(prev => !prev);
1227
+ };
1228
+
1229
+ // Calculate if history exists
1230
+ const hasHistory = imageHistory && imageHistory.length > 0;
1231
+
1232
+ // Add this helper function for image compression
1233
+ const compressImage = useCallback(async (dataUrl, maxWidth = 1200) => {
1234
+ return new Promise((resolve) => {
1235
+ const img = new Image();
1236
+ img.onload = () => {
1237
+ // Create a canvas to resize the image
1238
+ const canvas = document.createElement('canvas');
1239
+ const ctx = canvas.getContext('2d');
1240
+
1241
+ // Calculate new dimensions
1242
+ let width = img.width;
1243
+ let height = img.height;
1244
+
1245
+ if (width > maxWidth) {
1246
+ height = (height * maxWidth) / width;
1247
+ width = maxWidth;
1248
+ }
1249
+
1250
+ canvas.width = width;
1251
+ canvas.height = height;
1252
+
1253
+ // Draw and export as JPEG with lower quality
1254
+ ctx.fillStyle = '#FFFFFF';
1255
+ ctx.fillRect(0, 0, width, height);
1256
+ ctx.drawImage(img, 0, 0, width, height);
1257
+ resolve(canvas.toDataURL('image/jpeg', 0.85));
1258
+ };
1259
+ img.src = dataUrl;
1260
+ });
1261
+ }, []);
1262
+
1263
+ // Add this new function to handle using a library image as template
1264
+ const handleUseAsTemplate = useCallback(async (imageUrl) => {
1265
+ console.log('Using library image as template:', imageUrl);
1266
+
1267
+ // Show loading state with specific messages for each step
1268
+ setTemplateLoadingMessage("Preparing template...");
1269
+ setIsTemplateLoading(true);
1270
+
1271
+ try {
1272
+ // 1. Create a material from the image
1273
+ // First, fetch the image and convert to base64
1274
+ const response = await fetch(imageUrl);
1275
+ const blob = await response.blob();
1276
+
1277
+ // Convert blob to base64
1278
+ const reader = new FileReader();
1279
+ const imageDataPromise = new Promise((resolve) => {
1280
+ reader.onloadend = () => resolve(reader.result);
1281
+ reader.readAsDataURL(blob);
1282
+ });
1283
+
1284
+ const imageDataUrl = await imageDataPromise;
1285
+
1286
+ // Process with visual-enhance-prompt API (compress the image first)
1287
+ setTemplateLoadingMessage("Analyzing image...");
1288
+ const compressedImage = await compressImage(imageDataUrl, 1200);
1289
+
1290
+ // Get custom API key if it exists
1291
+ const customApiKey = localStorage.getItem("geminiApiKey");
1292
+
1293
+ // Call the visual-enhance-prompt API
1294
+ const promptResponse = await fetch('/api/visual-enhance-prompt', {
1295
+ method: 'POST',
1296
+ headers: { 'Content-Type': 'application/json' },
1297
+ body: JSON.stringify({
1298
+ image: compressedImage,
1299
+ customApiKey,
1300
+ basePrompt: 'Transform this sketch into a material with professional studio lighting against a pure black background. Render it in Cinema 4D with Octane for a high-end 3D visualization.'
1301
+ }),
1302
+ });
1303
+
1304
+ if (!promptResponse.ok) {
1305
+ throw new Error(`API returned ${promptResponse.status}`);
1306
+ }
1307
+
1308
+ const promptData = await promptResponse.json();
1309
+
1310
+ // 2. Add material to StyleSelector
1311
+ if (promptData.enhancedPrompt && promptData.suggestedName) {
1312
+ setTemplateLoadingMessage("Creating material...");
1313
+ // Create material object - use a smaller compressed image for thumbnail
1314
+ const thumbnailImage = await compressImage(imageDataUrl, 300);
1315
+
1316
+ const materialObj = {
1317
+ name: promptData.suggestedName,
1318
+ prompt: promptData.enhancedPrompt,
1319
+ image: thumbnailImage // Use compressed thumbnail
1320
+ };
1321
+
1322
+ // Add material to library and get the key
1323
+ const materialKey = addMaterialToLibrary(materialObj);
1324
+
1325
+ // Select this new material
1326
+ setStyleMode(materialKey);
1327
+
1328
+ // 3. Convert the library image to a doodle and render in Canvas
1329
+ setTemplateLoadingMessage("Converting to doodle...");
1330
+ const doodleResponse = await fetch('/api/convert-to-doodle', {
1331
+ method: 'POST',
1332
+ headers: { 'Content-Type': 'application/json' },
1333
+ body: JSON.stringify({
1334
+ imageData: compressedImage.split(',')[1],
1335
+ customApiKey
1336
+ }),
1337
+ });
1338
+
1339
+ if (!doodleResponse.ok) {
1340
+ throw new Error(`Doodle conversion API returned ${doodleResponse.status}`);
1341
+ }
1342
+
1343
+ const doodleData = await doodleResponse.json();
1344
+
1345
+ if (doodleData.success && doodleData.imageData) {
1346
+ // Render the doodle on the canvas
1347
+ const mainCtx = canvasRef.current?.getContext("2d");
1348
+ if (mainCtx && canvasRef.current) {
1349
+ const img = new Image();
1350
+ img.onload = () => {
1351
+ // Clear canvas
1352
+ mainCtx.fillStyle = '#FFFFFF';
1353
+ mainCtx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
1354
+
1355
+ // Calculate appropriate dimensions while maintaining aspect ratio
1356
+ // and respecting the current canvas dimensions
1357
+ const canvasWidth = canvasRef.current.width;
1358
+ const canvasHeight = canvasRef.current.height;
1359
+
1360
+ const imgRatio = img.width / img.height;
1361
+ const canvasRatio = canvasWidth / canvasHeight;
1362
+
1363
+ // Declare variables separately to fix linter warning
1364
+ let drawWidth = 0;
1365
+ let drawHeight = 0;
1366
+ let x = 0;
1367
+ let y = 0;
1368
+
1369
+ if (imgRatio > canvasRatio) {
1370
+ // Image is wider relative to canvas
1371
+ drawWidth = canvasWidth * 0.8;
1372
+ drawHeight = drawWidth / imgRatio;
1373
+ x = canvasWidth * 0.1;
1374
+ y = (canvasHeight - drawHeight) / 2;
1375
+ } else {
1376
+ // Image is taller relative to canvas
1377
+ drawHeight = canvasHeight * 0.8;
1378
+ drawWidth = drawHeight * imgRatio;
1379
+ x = (canvasWidth - drawWidth) / 2;
1380
+ y = canvasHeight * 0.1;
1381
+ }
1382
+
1383
+ // Draw doodle
1384
+ mainCtx.drawImage(img, x, y, drawWidth, drawHeight);
1385
+
1386
+ // Save canvas state
1387
+ if (typeof saveCanvasState === 'function') {
1388
+ saveCanvasState();
1389
+ }
1390
+
1391
+ // Mark as having drawing
1392
+ setHasDrawing(true);
1393
+
1394
+ // 4. Show the original image in the display canvas first
1395
+ setTemplateLoadingMessage("Generating material preview...");
1396
+
1397
+ // Draw the original image to the display canvas
1398
+ if (displayCanvasRef.current) {
1399
+ const displayCtx = displayCanvasRef.current.getContext("2d");
1400
+ if (displayCtx) {
1401
+ // Create a new image for the display canvas
1402
+ const displayImg = new Image();
1403
+ displayImg.onload = () => {
1404
+ // Clear display canvas first
1405
+ displayCtx.clearRect(0, 0, displayCanvasRef.current.width, displayCanvasRef.current.height);
1406
+
1407
+ // Fill with black background for letterboxing
1408
+ displayCtx.fillStyle = "#000000";
1409
+ displayCtx.fillRect(0, 0, displayCanvasRef.current.width, displayCanvasRef.current.height);
1410
+
1411
+ // Calculate aspect ratios for display canvas
1412
+ const imgRatio = displayImg.width / displayImg.height;
1413
+ const canvasRatio = displayCanvasRef.current.width / displayCanvasRef.current.height;
1414
+
1415
+ // Declare variables separately
1416
+ let dispDrawWidth = 0;
1417
+ let dispDrawHeight = 0;
1418
+ let dispX = 0;
1419
+ let dispY = 0;
1420
+
1421
+ if (imgRatio > canvasRatio) {
1422
+ // Image is wider than canvas
1423
+ dispDrawWidth = displayCanvasRef.current.width;
1424
+ dispDrawHeight = displayCanvasRef.current.width / imgRatio;
1425
+ dispX = 0;
1426
+ dispY = (displayCanvasRef.current.height - dispDrawHeight) / 2;
1427
+ } else {
1428
+ // Image is taller than canvas
1429
+ dispDrawHeight = displayCanvasRef.current.height;
1430
+ dispDrawWidth = displayCanvasRef.current.height * imgRatio;
1431
+ dispX = (displayCanvasRef.current.width - dispDrawWidth) / 2;
1432
+ dispY = 0;
1433
+ }
1434
+
1435
+ // Draw the image with letterboxing
1436
+ displayCtx.drawImage(displayImg, dispX, dispY, dispDrawWidth, dispDrawHeight);
1437
+
1438
+ // Set flag to indicate we have generated content
1439
+ setHasGeneratedContent(true);
1440
+
1441
+ // 5. Finally, trigger generation to show the styled version
1442
+ // Close the library view and finish the template process
1443
+ setShowLibrary(false);
1444
+
1445
+ // Slight delay before starting generation
1446
+ setTimeout(() => {
1447
+ handleGeneration();
1448
+
1449
+ // Turn off template loading
1450
+ setIsTemplateLoading(false);
1451
+ setTemplateLoadingMessage("");
1452
+ }, 500);
1453
+ };
1454
+
1455
+ // Load the original image for display
1456
+ displayImg.src = imageUrl;
1457
+ }
1458
+ } else {
1459
+ // If no display canvas, just trigger generation and finish
1460
+ handleGeneration();
1461
+ setShowLibrary(false);
1462
+ setIsTemplateLoading(false);
1463
+ setTemplateLoadingMessage("");
1464
+ }
1465
+ };
1466
+
1467
+ img.src = `data:image/png;base64,${doodleData.imageData}`;
1468
+ } else {
1469
+ throw new Error("Canvas context unavailable");
1470
+ }
1471
+ } else {
1472
+ throw new Error("Failed to convert to doodle");
1473
+ }
1474
+ } else {
1475
+ throw new Error("Failed to analyze image");
1476
+ }
1477
+ } catch (error) {
1478
+ console.error('Error using image as template:', error);
1479
+ toast.error('Failed to use image as template');
1480
+ setIsTemplateLoading(false);
1481
+ setTemplateLoadingMessage("");
1482
+ }
1483
+ }, [compressImage, handleGeneration]);
1484
+
1485
+ return (
1486
+ <div className="flex min-h-screen flex-col items-center justify-start bg-gray-50 p-2 md:p-4 overflow-y-auto">
1487
+ {showLibrary ? (
1488
+ <LibraryPage onBack={toggleLibrary} onUseAsTemplate={handleUseAsTemplate} />
1489
+ ) : (
1490
+ <div className="w-full max-w-[1800px] mx-auto pb-4">
1491
+ <div className="space-y-1">
1492
+ <div className="flex flex-col sm:flex-row items-start justify-between gap-2">
1493
+ <div className="flex-shrink-0">
1494
+ <Header />
1495
+ </div>
1496
+ {/* Header Buttons Section - only visible on desktop */}
1497
+ <div className="hidden md:flex items-center gap-2 mt-auto sm:mt-8">
1498
+ <HeaderButtons
1499
+ hasHistory={hasHistory}
1500
+ openHistoryModal={openHistoryModal}
1501
+ toggleLibrary={toggleLibrary}
1502
+ handleSaveImage={handleSaveImage}
1503
+ isLoading={isLoading}
1504
+ hasGeneratedContent={hasGeneratedContent}
1505
+ />
1506
+ </div>
1507
+ </div>
1508
+
1509
+ {/* New single row layout */}
1510
+ <div className="flex flex-col md:flex-row items-stretch gap-4 w-full md:mt-4">
1511
+ {/* Toolbar - fixed width on desktop, full width horizontal on mobile */}
1512
+ <div className="w-full md:w-[60px] md:flex-shrink-0">
1513
+ {/* Mobile toolbar (horizontal) */}
1514
+ <div className="block md:hidden w-fit">
1515
+ <ToolBar
1516
+ currentTool={currentTool}
1517
+ setCurrentTool={setCurrentTool}
1518
+ handleUndo={handleUndo}
1519
+ clearCanvas={clearCanvas}
1520
+ orientation="horizontal"
1521
+ currentWidth={penWidth}
1522
+ setStrokeWidth={handleStrokeWidth}
1523
+ currentDimension={currentDimension}
1524
+ onDimensionChange={handleDimensionChange}
1525
+ />
1526
+ </div>
1527
+
1528
+ {/* Desktop toolbar (vertical) */}
1529
+ <div className="hidden md:block">
1530
+ <ToolBar
1531
+ currentTool={currentTool}
1532
+ setCurrentTool={setCurrentTool}
1533
+ handleUndo={handleUndo}
1534
+ clearCanvas={clearCanvas}
1535
+ orientation="vertical"
1536
+ currentWidth={penWidth}
1537
+ setStrokeWidth={handleStrokeWidth}
1538
+ currentDimension={currentDimension}
1539
+ onDimensionChange={handleDimensionChange}
1540
+ />
1541
+ </div>
1542
+ </div>
1543
+
1544
+ {/* Main content area */}
1545
+ <div className="flex-1 flex flex-col gap-4">
1546
+ {/* Canvas row */}
1547
+ <div className="flex flex-col md:flex-row gap-2">
1548
+ {/* Canvas */}
1549
+ <div className="flex-1 w-full relative">
1550
+ <Canvas
1551
+ ref={canvasComponentRef}
1552
+ canvasRef={canvasRef}
1553
+ currentTool={currentTool}
1554
+ isDrawing={isDrawing}
1555
+ startDrawing={startDrawing}
1556
+ draw={draw}
1557
+ stopDrawing={stopDrawing}
1558
+ handleCanvasClick={handleCanvasClick}
1559
+ handlePenClick={handlePenClick}
1560
+ handleGeneration={handleGeneration}
1561
+ tempPoints={tempPoints}
1562
+ setTempPoints={setTempPoints}
1563
+ handleUndo={handleUndo}
1564
+ clearCanvas={clearCanvas}
1565
+ setCurrentTool={setCurrentTool}
1566
+ currentDimension={currentDimension}
1567
+ currentColor={penColor}
1568
+ currentWidth={penWidth}
1569
+ onImageUpload={handleImageUpload}
1570
+ onGenerate={handleGeneration}
1571
+ isGenerating={isLoading}
1572
+ setIsGenerating={setIsLoading}
1573
+ saveCanvasState={saveCanvasState}
1574
+ onDrawingChange={setHasDrawing}
1575
+ styleMode={styleMode}
1576
+ setStyleMode={setStyleMode}
1577
+ isSendingToDoodle={isSendingToDoodle}
1578
+ />
1579
+ </div>
1580
+
1581
+ {/* Display Canvas */}
1582
+ <div className="flex-1 w-full">
1583
+ <DisplayCanvas
1584
+ displayCanvasRef={displayCanvasRef}
1585
+ isLoading={isLoading}
1586
+ handleRegenerate={handleRegenerate}
1587
+ hasGeneratedContent={hasGeneratedContent}
1588
+ currentDimension={currentDimension}
1589
+ onOpenHistory={openHistoryModal}
1590
+ onRefineImage={handleImageRefinement}
1591
+ onSendToDoodle={handleSendToDoodle}
1592
+ hasHistory={hasHistory}
1593
+ openHistoryModal={openHistoryModal}
1594
+ toggleLibrary={toggleLibrary}
1595
+ handleSaveImage={handleSaveImage}
1596
+ />
1597
+ </div>
1598
+ </div>
1599
+ </div>
1600
+ </div>
1601
+ </div>
1602
+ </div>
1603
+ )}
1604
+
1605
+ <ErrorModal
1606
+ showErrorModal={showErrorModal}
1607
+ closeErrorModal={closeErrorModal}
1608
+ customApiKey={customApiKey}
1609
+ setCustomApiKey={setCustomApiKey}
1610
+ handleApiKeySubmit={handleApiKeySubmit}
1611
+ />
1612
+
1613
+ <ApiKeyModal
1614
+ isOpen={showApiKeyModal}
1615
+ onClose={() => setShowApiKeyModal(false)}
1616
+ onSubmit={handleApiKeySubmit}
1617
+ initialValue={customApiKey}
1618
+ />
1619
+
1620
+ <TextInput
1621
+ isTyping={isTyping}
1622
+ textInputRef={textInputRef}
1623
+ textInput={textInput}
1624
+ setTextInput={setTextInput}
1625
+ handleTextInput={handleTextInput}
1626
+ textPosition={textPosition}
1627
+ />
1628
+
1629
+ <HistoryModal
1630
+ isOpen={isHistoryModalOpen}
1631
+ onClose={() => setIsHistoryModalOpen(false)}
1632
+ history={imageHistory}
1633
+ onSelectImage={handleSelectHistoricalImage}
1634
+ currentDimension={currentDimension}
1635
+ />
1636
+
1637
+ {/* Template loading overlay */}
1638
+ {isTemplateLoading && (
1639
+ <div className="fixed inset-0 flex flex-col items-center justify-center bg-black/50 z-50">
1640
+ <div className="bg-white shadow-lg rounded-xl p-6 flex flex-col items-center max-w-md">
1641
+ <LoaderCircle className="w-12 h-12 text-blue-600 animate-spin mb-4" />
1642
+ <p className="text-gray-900 font-medium text-lg">{templateLoadingMessage || "Processing template..."}</p>
1643
+ <p className="text-gray-500 text-sm mt-2">This may take a moment</p>
1644
+ </div>
1645
+ </div>
1646
+ )}
1647
+ </div>
1648
+ );
1649
+ };
1650
+
1651
+ export default CanvasContainer;
components/DimensionSelector.js ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Square, RectangleVertical, RectangleHorizontal, ChevronDown } from 'lucide-react';
2
+ import { useState, useEffect, useRef } from 'react';
3
+
4
+ const dimensions = [
5
+ {
6
+ id: 'landscape',
7
+ label: '3:2',
8
+ width: 1500,
9
+ height: 1000,
10
+ icon: RectangleHorizontal
11
+ },
12
+ {
13
+ id: 'square',
14
+ label: '1:1',
15
+ width: 1000,
16
+ height: 1000,
17
+ icon: Square
18
+ },
19
+ {
20
+ id: 'portrait',
21
+ label: '4:5',
22
+ width: 1000,
23
+ height: 1250,
24
+ icon: RectangleVertical
25
+ }
26
+
27
+ ];
28
+
29
+ const DimensionSelector = ({ currentDimension = dimensions[0], onDimensionChange }) => {
30
+ const [isOpen, setIsOpen] = useState(false);
31
+ const dropdownRef = useRef(null);
32
+
33
+ // Handle both click and hover for better mobile and desktop experience
34
+ const handleToggle = () => setIsOpen(!isOpen);
35
+
36
+ // We don't want to close on mouse leave immediately
37
+ const timeoutRef = useRef(null);
38
+
39
+ const handleMouseEnter = () => {
40
+ if (timeoutRef.current) {
41
+ clearTimeout(timeoutRef.current);
42
+ timeoutRef.current = null;
43
+ }
44
+ setIsOpen(true);
45
+ };
46
+
47
+ const handleMouseLeave = () => {
48
+ timeoutRef.current = setTimeout(() => {
49
+ setIsOpen(false);
50
+ }, 100);
51
+ };
52
+
53
+ // Close dropdown when clicking outside
54
+ useEffect(() => {
55
+ const handleClickOutside = (event) => {
56
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
57
+ setIsOpen(false);
58
+ }
59
+ };
60
+
61
+ document.addEventListener('mousedown', handleClickOutside);
62
+ return () => {
63
+ document.removeEventListener('mousedown', handleClickOutside);
64
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
65
+ };
66
+ }, []);
67
+
68
+ // Find current dimension
69
+ const current = dimensions.find(d => d.id === currentDimension.id) || dimensions[0];
70
+ const Icon = current.icon;
71
+
72
+ return (
73
+ <div
74
+ className="relative z-50"
75
+ ref={dropdownRef}
76
+ onMouseEnter={handleMouseEnter}
77
+ onMouseLeave={handleMouseLeave}
78
+ >
79
+ {/* Current selection */}
80
+ <button
81
+ type="button"
82
+ className="w-full flex flex-row md:flex-col items-center justify-center md:p-2 md:py-4 px-2 py-2 rounded-lg transition-colors hover:bg-gray-50"
83
+ onClick={handleToggle}
84
+ style={{ opacity: isOpen ? 1 : 0.7 }}
85
+ >
86
+ <Icon className="w-5 h-5 text-gray-900 mr-2 md:mr-0 md:mb-1" />
87
+ <span className="text-sm text-gray-900">{current.label}</span>
88
+ </button>
89
+
90
+ {/* Dropdown */}
91
+ {isOpen && (
92
+ <>
93
+ {/* Invisible bridge element that extends to the dropdown */}
94
+ <div className="hidden md:block absolute left-[calc(100%-4px)] top-0 h-full w-8"
95
+ onMouseEnter={handleMouseEnter}
96
+ style={{ pointerEvents: 'auto' }} />
97
+
98
+ <div className="absolute left-0 md:left-[calc(100%+4px)] top-0 bg-white rounded-xl shadow-soft
99
+ border border-gray-200 z-10 min-w-[80px]"
100
+ onMouseEnter={handleMouseEnter}
101
+ onMouseLeave={handleMouseLeave}>
102
+ <div className="py-1">
103
+ {dimensions.map((dim) => {
104
+ const Icon = dim.icon;
105
+ return (
106
+ <button
107
+ type="button"
108
+ key={dim.id}
109
+ onClick={() => {
110
+ onDimensionChange(dim);
111
+ setIsOpen(false);
112
+ }}
113
+ className={`w-full p-2 flex items-center gap-2 hover:bg-gray-50 transition-colors whitespace-nowrap ${
114
+ currentDimension.id === dim.id ? 'bg-gray-100 text-gray-900' : 'text-gray-600'
115
+ } ${dim.id === dimensions[0].id ? 'rounded-t-lg' : ''} ${dim.id === dimensions[dimensions.length-1].id ? 'rounded-b-lg' : ''}`}
116
+ >
117
+ <Icon className="w-4 h-4" />
118
+ <span className="text-sm">{dim.label}</span>
119
+ </button>
120
+ );
121
+ })}
122
+ </div>
123
+ </div>
124
+ </>
125
+ )}
126
+ </div>
127
+ );
128
+ };
129
+
130
+ export default DimensionSelector;
components/DisplayCanvas.js ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LoaderCircle, ImageIcon, Send, RotateCw, Maximize, Wand2, Sun, Plus, Palette, Image as ImageLucide, Brush, SendToBack, Download, ArrowLeftRight } from 'lucide-react';
2
+ import { useEffect, useState, useCallback } from 'react';
3
+ import ImageRefiner from './ImageRefiner';
4
+ import HeaderButtons from './HeaderButtons';
5
+
6
+ // Update REFINEMENT_SUGGESTIONS with cleaner labels and full prompts
7
+ const REFINEMENT_SUGGESTIONS = [
8
+ { label: 'Rotate', prompt: 'Can you rotate this by ', icon: RotateCw },
9
+ { label: 'Add light', prompt: 'Can you add a light from the ', icon: Sun },
10
+ { label: 'Add object', prompt: 'Can you add a ', icon: Plus },
11
+ { label: 'Background', prompt: 'Can you change the background to ', icon: ImageLucide },
12
+ { label: 'Color', prompt: 'Can you make the color more ', icon: Palette },
13
+ { label: 'Scale', prompt: 'Can you make this ', icon: Maximize },
14
+ { label: 'Lighting', prompt: 'Can you make the lighting more ', icon: Sun },
15
+ { label: 'Style', prompt: 'Can you make it look more ', icon: Wand2 },
16
+ { label: 'Material', prompt: 'Can you change the material to ', icon: Wand2 },
17
+ // Add more suggestions as needed...
18
+ ];
19
+
20
+ // Update SURPRISE_REFINEMENTS with more fun, bonkers prompts
21
+ const SURPRISE_REFINEMENTS = [
22
+ // Open-ended prompts (higher probability)
23
+ "Do something cool with this. I leave it up to you!",
24
+ "Surprise me! Take this image somewhere unexpected.",
25
+ "Transform this however you want. Be creative!",
26
+ "Do something wild with this image. No limits!",
27
+ "Make this image magical in your own way.",
28
+ "Take creative freedom with this image. Surprise me!",
29
+ "Show me what you can do with this. Go crazy!",
30
+ "Transform this in a way I wouldn't expect.",
31
+ "Have fun with this and do whatever inspires you.",
32
+ "Go wild with this image! Show me something amazing.",
33
+ "Put your own creative spin on this image.",
34
+ "Reimagine this image however you want. Be bold!",
35
+ "Do something unexpected with this. Totally up to you!",
36
+ "Surprise me with your creativity. Anything goes!",
37
+ "Make this extraordinary in whatever way you choose.",
38
+ "Show off your creative abilities with this image!",
39
+ "Take this in any direction that excites you!",
40
+ "Transform this however your imagination guides you.",
41
+ "Make this magical in your own unique way.",
42
+ "Do something fun and unexpected with this!",
43
+ "Surprise me! Show me your creativity.",
44
+ "Make this more beautiful <3",
45
+ "Put your artistic spin on this image!",
46
+ "Let your imagination run wild with this!",
47
+ "Take this image to a whole new level of awesome!",
48
+ "Make this image extraordinary in your own way.",
49
+ "Do something fantastic with this. Full creative freedom!",
50
+ "Surprise me with a totally unexpected transformation!",
51
+ "Go nuts with this! Show me something incredible!",
52
+ "Add your own wild twist to this image!",
53
+ "Make this image come alive however you want!",
54
+ "Transform this in the most creative way possible!",
55
+ "Go crazy with this. I want to be wowed :))",
56
+ "Do whatever magical things you want with this image!",
57
+ "Reinvent this image however inspires you!",
58
+
59
+ // Specific wild ideas (lower probability)
60
+ "Can you add this to outer space with aliens having a BBQ?",
61
+ "Can you add a giraffe wearing a tuxedo to this?",
62
+ "Can you make tiny vikings invade this image?",
63
+ "Can you turn this into an ice cream sundae being eaten by robots?",
64
+ "Can you make this float in a sea of rainbow soup?",
65
+ "Can you add dancing pickles to this image?",
66
+ "Can you make this the centerpiece of an alien museum?",
67
+ "Can you add this to a cereal bowl being eaten by a giant?",
68
+ "Can you make this the star of a bizarre music video?",
69
+ "Can you add tiny dinosaurs having a tea party?",
70
+ "Can you turn this into something from a fever dream?",
71
+ "Can you make this the main character in a surreal fairytale?",
72
+ "Can you put this in the middle of a candy landscape?",
73
+ "Can you add this to a world where physics works backwards?",
74
+ "Can you make this the prize in a cosmic game show?",
75
+ "Can you add tiny people worshipping this as a deity?",
76
+ "Can you put this in the paws of a giant cosmic cat?",
77
+ "Can you make this wearing sunglasses and surfing?",
78
+ "Can you add this to a world made entirely of cheese?",
79
+ "Can you make this the centerpiece of a goblin birthday party?",
80
+ "Can you transform this into a cloud creature floating in the sky?",
81
+ "Can you add this to a world where everything is made of pasta?",
82
+ "Can you turn this into a piñata at a monster celebration?",
83
+ "Can you add this to outer space?",
84
+ "Can you add this to a landscape made of breakfast foods?",
85
+ "Can you make this the conductor of an orchestra of unusual animals?",
86
+ "Can you turn this into a strange plant growing in an alien garden?",
87
+ "Can you add this to a world inside a snow globe?",
88
+ "Can you make this the secret ingredient in a witch's cauldron?",
89
+ "Can you turn this into a superhero with an unusual power?",
90
+ "Can you make this swimming in a sea of jelly beans?",
91
+ "Can you add this to a planet where everything is upside down?",
92
+ "Can you make this the treasure in a dragon's unusual collection?",
93
+ "Can you transform this into a character in a bizarre cartoon?",
94
+ "Can you add this to a world where shadows come to life?"
95
+ ];
96
+
97
+ const DisplayCanvas = ({
98
+ displayCanvasRef,
99
+ isLoading,
100
+ handleSaveImage,
101
+ handleRegenerate,
102
+ hasGeneratedContent = false,
103
+ currentDimension,
104
+ onOpenHistory,
105
+ onRefineImage,
106
+ onSendToDoodle,
107
+ hasHistory,
108
+ openHistoryModal,
109
+ toggleLibrary
110
+ }) => {
111
+ const [showPlaceholder, setShowPlaceholder] = useState(true);
112
+ const [inputValue, setInputValue] = useState('');
113
+ const [showDoodleTooltip, setShowDoodleTooltip] = useState(false);
114
+ const [showSaveTooltip, setShowSaveTooltip] = useState(false);
115
+ const [isHoveringCanvas, setIsHoveringCanvas] = useState(false);
116
+
117
+ // Update placeholder visibility when loading or content prop changes
118
+ useEffect(() => {
119
+ if (hasGeneratedContent) {
120
+ setShowPlaceholder(false);
121
+ } else if (isLoading) {
122
+ setShowPlaceholder(true);
123
+ }
124
+ }, [isLoading, hasGeneratedContent]);
125
+
126
+ const handleSubmit = (e) => {
127
+ e.preventDefault();
128
+ if (!inputValue.trim()) return;
129
+ onRefineImage(inputValue);
130
+ setInputValue('');
131
+ };
132
+
133
+ const handleSuggestionClick = (suggestion) => {
134
+ setInputValue(suggestion.prompt);
135
+ document.querySelector('input[name="refiner"]').focus();
136
+ };
137
+
138
+ const handleSurpriseMe = () => {
139
+ const randomPrompt = SURPRISE_REFINEMENTS[Math.floor(Math.random() * SURPRISE_REFINEMENTS.length)];
140
+ setInputValue(randomPrompt);
141
+ document.querySelector('input[name="refiner"]').focus();
142
+ };
143
+
144
+ const handleSendToDoodle = useCallback(() => {
145
+ if (displayCanvasRef.current && onSendToDoodle) {
146
+ const imageDataUrl = displayCanvasRef.current.toDataURL('image/png');
147
+ onSendToDoodle(imageDataUrl);
148
+ }
149
+ }, [displayCanvasRef, onSendToDoodle]);
150
+
151
+ // Function to handle clicking the canvas for regeneration
152
+ const handleCanvasClickForRegenerate = () => {
153
+ if (hasGeneratedContent && !isLoading) {
154
+ handleRegenerate();
155
+ }
156
+ };
157
+
158
+ // Placeholder function for fullscreen action
159
+ const handleFullscreen = (e) => {
160
+ e.stopPropagation(); // Prevent triggering the refresh
161
+ alert('Fullscreen action triggered!');
162
+ // TODO: Implement actual fullscreen logic (e.g., using Fullscreen API or opening image in new tab)
163
+ };
164
+
165
+ return (
166
+ <div className="flex flex-col">
167
+ {/* Canvas container with fixed aspect ratio */}
168
+ <div
169
+ className="relative w-full"
170
+ style={{ aspectRatio: `${currentDimension.width} / ${currentDimension.height}` }}
171
+ >
172
+ <button
173
+ type="button"
174
+ className="w-full h-full absolute inset-0 z-10 cursor-pointer appearance-none bg-transparent border-0 group"
175
+ onMouseEnter={() => setIsHoveringCanvas(true)}
176
+ onMouseLeave={() => setIsHoveringCanvas(false)}
177
+ onClick={handleCanvasClickForRegenerate}
178
+ disabled={!hasGeneratedContent || isLoading}
179
+ aria-label="Regenerate image"
180
+ />
181
+ <canvas
182
+ ref={displayCanvasRef}
183
+ width={currentDimension.width}
184
+ height={currentDimension.height}
185
+ className="absolute inset-0 w-full h-full border border-gray-300 bg-white rounded-xl shadow-soft"
186
+ aria-label="Generated image canvas"
187
+ />
188
+
189
+ {/* Loading overlay */}
190
+ {isLoading && (
191
+ <div className="absolute inset-0 flex items-center justify-center bg-black/5 rounded-xl">
192
+ <div className="bg-white/90 rounded-full p-3 shadow-medium">
193
+ <LoaderCircle className="w-8 h-8 animate-spin text-gray-700" />
194
+ </div>
195
+ </div>
196
+ )}
197
+
198
+ {/* Placeholder overlay */}
199
+ {showPlaceholder && !isLoading && !hasGeneratedContent && (
200
+ <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
201
+ <ImageIcon className="w-7 h-7 text-gray-400 mb-2" />
202
+ <p className="text-gray-400 text-lg font-medium">Generation will appear here</p>
203
+ </div>
204
+ )}
205
+
206
+ {/* Hover-to-Refresh Overlay */}
207
+ {isHoveringCanvas && hasGeneratedContent && !isLoading && (
208
+ // Added transition classes for smoother appearance
209
+ // Changed bg-black/20 to bg-black/10 for a gentler overlay
210
+ <div className="absolute inset-0 flex items-center justify-center bg-black/0 rounded-xl pointer-events-none transition-opacity duration-300 ease-in-out">
211
+ {/* Container for icon - allows separate click handling */}
212
+ {/* Removed Maximize icon */}
213
+ <div className="flex items-center gap-4 bg-white/90 rounded-full p-3 shadow-medium pointer-events-auto">
214
+ {/* Refresh Icon (clickable via parent div onClick) */}
215
+ <RotateCw className="w-8 h-8 text-gray-700" title="Click canvas to regenerate"/>
216
+ </div>
217
+ </div>
218
+ )}
219
+ </div>
220
+
221
+ {/* Action bar and refiner section - Combined layout */}
222
+ <div className="mt-4 flex items-stretch justify-between gap-2 max-w-full">
223
+ {/* Left side wrapper for Refiner and action buttons */}
224
+ <div className="flex items-stretch gap-2 flex-1 min-w-0">
225
+ {/* Refiner input - only shown when there's generated content */}
226
+ {hasGeneratedContent ? (
227
+ <>
228
+ <form onSubmit={handleSubmit} className="flex-1 min-w-0">
229
+ <div className="group flex items-center bg-gray-50 focus-within:bg-white rounded-xl shadow-soft p-2 border border-gray-200 focus-within:border-gray-300 h-14 transition-colors">
230
+ <input
231
+ name="refiner"
232
+ type="text"
233
+ value={inputValue}
234
+ onChange={(e) => setInputValue(e.target.value)}
235
+ placeholder="Type to refine the image..."
236
+ disabled={isLoading}
237
+ className="flex-1 px-2 bg-transparent border-none text-sm text-gray-400 placeholder-gray-300 group-focus-within:text-gray-600 group-focus-within:placeholder-gray-400 focus:outline-none transition-colors"
238
+ />
239
+ <button
240
+ type="submit"
241
+ disabled={isLoading || !inputValue.trim()}
242
+ className="p-2 rounded-lg text-gray-400 group-focus-within:text-gray-700 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
243
+ aria-label="Send refinement"
244
+ >
245
+ <Send className="w-5 h-5" />
246
+ </button>
247
+ </div>
248
+ </form>
249
+ {/* Action buttons wrapper */}
250
+ <div className="flex gap-2">
251
+ {/* "Send to Doodle" button with tooltip */}
252
+ <div className="relative">
253
+ <button
254
+ type="button"
255
+ onClick={handleSendToDoodle}
256
+ disabled={isLoading}
257
+ onMouseEnter={() => setShowDoodleTooltip(true)}
258
+ onMouseLeave={() => setShowDoodleTooltip(false)}
259
+ className="group w-14 h-14 p-2 rounded-lg bg-gray-50 border border-gray-200 shadow-soft hover:bg-white hover:border-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
260
+ aria-label="Send image back to doodle canvas"
261
+ >
262
+ <ArrowLeftRight className="w-5 h-5 text-gray-400 group-hover:text-gray-700 transition-colors" />
263
+ </button>
264
+ {/* Custom Tooltip - Right aligned */}
265
+ {showDoodleTooltip && (
266
+ <div className="absolute bottom-full right-0 mb-2 px-3 py-1.5 bg-white border border-gray-200 text-gray-700 text-xs rounded-lg shadow-soft whitespace-nowrap pointer-events-none">
267
+ Send Back to Doodle Canvas
268
+ </div>
269
+ )}
270
+ </div>
271
+ </div>
272
+ </>
273
+ ) : (
274
+ // Placeholder div to maintain layout when no content
275
+ <div className="flex-1 h-0" />
276
+ )}
277
+ </div>
278
+ </div>
279
+
280
+ {/* Refined suggestion chips */}
281
+ {hasGeneratedContent && (
282
+ <div className="mt-4 flex flex-wrap gap-2 text-xs mb-4">
283
+ {/* Surprise Me button with updated styling */}
284
+ <button
285
+ type="button"
286
+ onClick={handleSurpriseMe}
287
+ className="group flex items-center gap-2 px-3 py-1 bg-gray-50 hover:bg-white rounded-full border border-gray-200 text-gray-400 hover:border-gray-300 transition-all focus:outline-none focus:ring-2 focus:ring-gray-200"
288
+ >
289
+ <Wand2 className="w-4 h-4 group-hover:text-gray-600" />
290
+ <span className="group-hover:text-gray-600">Surprise Me</span>
291
+ </button>
292
+
293
+ {/* Regular suggestion buttons with updated styling */}
294
+ {REFINEMENT_SUGGESTIONS.map((suggestion) => (
295
+ <button
296
+ key={`suggestion-${suggestion.label}`}
297
+ type="button"
298
+ onClick={() => handleSuggestionClick(suggestion)}
299
+ className="group flex items-center gap-2 px-3 py-1 bg-gray-50 hover:bg-white rounded-full border border-gray-200 text-gray-400 hover:border-gray-300 transition-all focus:outline-none focus:ring-2 focus:ring-gray-200"
300
+ >
301
+ <suggestion.icon className="w-4 h-4 group-hover:text-gray-600" />
302
+ <span className="group-hover:text-gray-600">{suggestion.label}</span>
303
+ </button>
304
+ ))}
305
+ </div>
306
+ )}
307
+
308
+ {/* Header buttons - Mobile only, appearing below refinement options */}
309
+ <div className="md:hidden flex flex-wrap justify-between items-center gap-2 mt-6 mb-2 w-full">
310
+ <div className="grid grid-cols-3 w-full gap-2">
311
+ <HeaderButtons
312
+ hasHistory={hasHistory}
313
+ openHistoryModal={openHistoryModal}
314
+ toggleLibrary={toggleLibrary}
315
+ handleSaveImage={handleSaveImage}
316
+ isLoading={isLoading}
317
+ hasGeneratedContent={hasGeneratedContent}
318
+ />
319
+ </div>
320
+ </div>
321
+ </div>
322
+ );
323
+ };
324
+
325
+ export default DisplayCanvas;
components/ErrorModal.js ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { X } from 'lucide-react';
2
+ import { useState, useEffect } from 'react';
3
+
4
+ const ErrorModal = ({
5
+ showErrorModal,
6
+ closeErrorModal,
7
+ customApiKey,
8
+ setCustomApiKey,
9
+ handleApiKeySubmit
10
+ }) => {
11
+ const [localApiKey, setLocalApiKey] = useState(customApiKey);
12
+
13
+ // Update local API key when prop changes
14
+ useEffect(() => {
15
+ setLocalApiKey(customApiKey);
16
+ }, [customApiKey]);
17
+
18
+ const handleSubmit = (e) => {
19
+ e.preventDefault();
20
+ handleApiKeySubmit(localApiKey);
21
+ };
22
+
23
+ return (
24
+ <>
25
+ {showErrorModal && (
26
+ <div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 overflow-y-auto modalBackdrop"
27
+ onClick={(e) => {
28
+ if (e.target.classList.contains('modalBackdrop')) {
29
+ closeErrorModal();
30
+ }
31
+ }}
32
+ >
33
+ <div className="bg-white p-6 rounded-xl shadow-medium max-w-md w-full mx-4 my-8">
34
+ <div className="flex flex-col gap-4">
35
+ <div className="flex items-center justify-between">
36
+ <h2 className="text-xl font-medium text-gray-800">API Quota Exceeded</h2>
37
+ <button
38
+ type="button"
39
+ onClick={closeErrorModal}
40
+ className="text-gray-500 hover:text-gray-700"
41
+ aria-label="Close"
42
+ >
43
+ <X className="w-5 h-5" />
44
+ </button>
45
+ </div>
46
+
47
+ <div className="text-gray-600">
48
+ <p className="mb-2">
49
+ You've exceeded your API quota. You can:
50
+ </p>
51
+ <ul className="list-disc ml-5 mb-4 space-y-1 text-gray-600">
52
+ <li>Wait for your quota to reset</li>
53
+ <li>Use your own API key from <a href="https://ai.google.dev/" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">Google AI Studio</a></li>
54
+ </ul>
55
+ </div>
56
+
57
+ <form onSubmit={handleSubmit} className="space-y-3">
58
+ <div>
59
+ <label
60
+ htmlFor="apiKey"
61
+ className="block text-sm font-medium text-gray-700 mb-1"
62
+ >
63
+ Your Gemini API Key
64
+ </label>
65
+ <input
66
+ id="apiKey"
67
+ type="text"
68
+ placeholder="Enter your Gemini API key"
69
+ value={localApiKey}
70
+ onChange={(e) => setLocalApiKey(e.target.value)}
71
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
72
+ />
73
+ <p className="text-xs text-gray-500 mt-1">Your API key will be saved locally and never sent to our servers.</p>
74
+ </div>
75
+ <div className="flex justify-end gap-3 pt-2">
76
+ <button
77
+ type="button"
78
+ onClick={closeErrorModal}
79
+ className="px-4 py-2 border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 hover:border-gray-300 transition-colors text-sm font-medium"
80
+ >
81
+ Cancel
82
+ </button>
83
+ <button
84
+ type="submit"
85
+ disabled={!localApiKey.trim()}
86
+ className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors text-sm font-medium"
87
+ >
88
+ Save API Key
89
+ </button>
90
+ </div>
91
+ </form>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ )}
96
+ </>
97
+ );
98
+ };
99
+
100
+ export default ErrorModal;
components/Header.js ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const Header = () => {
2
+ return (
3
+ <header className="w-full pt-2">
4
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 pb-0">
5
+ <div className="flex flex-col gap-0">
6
+ <h1 className="text-lg tracking-[-0.1px] text-gray-800"
7
+ style={{ fontFamily: "'Google Sans', sans-serif" }}>GEMINI 3D DRAWING</h1>
8
+ <p className="text-sm text-gray-400">
9
+ <span>By</span>{" "}
10
+ <a href="https://x.com/dev_valladares" target="_blank" rel="noreferrer" className="hover:text-gray-600 underline transition-colors">Dev Valladares</a>
11
+ {" "}&{" "}
12
+ <a href="https://x.com/trudypainter" target="_blank" rel="noreferrer" className="hover:text-gray-600 underline transition-colors">Trudy Painter</a>
13
+ {" "}
14
+
15
+
16
+ </p>
17
+ <span className="inline-flex items-center rounded-full mt-1.5 border border-gray-200 px-1.5 py-1 text-xs font-mono text-gray-400">
18
+ ⟡ Gemini 2.0 Native Image Generation ⟡
19
+ </span>
20
+ </div>
21
+ </div>
22
+ </header>
23
+ );
24
+ };
25
+
26
+ export default Header;
components/HeaderButtons.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Download, History as HistoryIcon, Library as LibraryIcon } from "lucide-react";
2
+
3
+ const HeaderButtons = ({
4
+ hasHistory,
5
+ openHistoryModal,
6
+ toggleLibrary,
7
+ handleSaveImage,
8
+ isLoading,
9
+ hasGeneratedContent
10
+ }) => {
11
+ return (
12
+ <>
13
+ <button
14
+ type="button"
15
+ onClick={openHistoryModal}
16
+ disabled={!hasHistory}
17
+ className={`group flex items-center justify-center gap-2 md:gap-2.5 px-2 md:px-5 h-14 rounded-full border text-sm shadow-soft transition-all focus:outline-none w-full md:w-auto ${
18
+ !hasHistory
19
+ ? 'bg-gray-50 border-gray-200 text-gray-300 cursor-not-allowed opacity-70'
20
+ : 'bg-gray-50 border-gray-200 text-gray-400 hover:bg-white hover:border-gray-300'
21
+ }`}
22
+ >
23
+ <HistoryIcon className={`w-5 h-5 ${!hasHistory ? 'text-gray-300' : 'text-gray-400 group-hover:text-gray-600'}`} />
24
+ <span className={`font-medium md:inline ${!hasHistory ? 'text-gray-300' : 'text-gray-400 group-hover:text-gray-600'}`}>History</span>
25
+ </button>
26
+ <button
27
+ type="button"
28
+ onClick={toggleLibrary}
29
+ className="group flex items-center justify-center gap-2 md:gap-2.5 px-2 md:px-5 h-14 bg-gray-50 hover:bg-white rounded-full border border-gray-200 text-gray-400 hover:border-gray-300 transition-all focus:outline-none focus:ring-2 focus:ring-gray-200 text-sm shadow-soft w-full md:w-auto"
30
+ >
31
+ <LibraryIcon className="w-5 h-5 group-hover:text-gray-600" />
32
+ <span className="group-hover:text-gray-600 font-medium">Gallery</span>
33
+ </button>
34
+ <button
35
+ type="button"
36
+ onClick={handleSaveImage}
37
+ disabled={isLoading || !hasGeneratedContent}
38
+ className="group flex items-center justify-center gap-2 md:gap-2.5 px-2 md:px-5 h-14 rounded-full border text-sm shadow-soft transition-all focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed bg-gray-50 border-gray-200 text-gray-400 hover:bg-white hover:border-gray-300 w-full md:w-auto"
39
+ >
40
+ <Download className={`w-5 h-5 ${isLoading || !hasGeneratedContent ? 'text-gray-400' : 'group-hover:text-gray-600'}`} />
41
+ <span className={isLoading || !hasGeneratedContent ? 'text-gray-400' : 'group-hover:text-gray-600 font-medium'}>Save</span>
42
+ </button>
43
+ </>
44
+ );
45
+ };
46
+
47
+ export default HeaderButtons;
components/HistoryModal.js ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { X, Calendar } from 'lucide-react';
2
+ import { useRef, useEffect } from 'react';
3
+
4
+ const HistoryModal = ({
5
+ isOpen,
6
+ onClose,
7
+ history,
8
+ onSelectImage,
9
+ currentDimension
10
+ }) => {
11
+ if (!isOpen) return null;
12
+
13
+ const modalContentRef = useRef(null);
14
+
15
+ // Handle click outside to close
16
+ useEffect(() => {
17
+ function handleClickOutside(event) {
18
+ if (modalContentRef.current && !modalContentRef.current.contains(event.target)) {
19
+ onClose();
20
+ }
21
+ }
22
+
23
+ // Add event listener when modal is open
24
+ document.addEventListener('mousedown', handleClickOutside);
25
+
26
+ // Clean up the event listener
27
+ return () => {
28
+ document.removeEventListener('mousedown', handleClickOutside);
29
+ };
30
+ }, [onClose]);
31
+
32
+ // Format date nicely
33
+ const formatDate = (timestamp) => {
34
+ const date = new Date(timestamp);
35
+ const today = new Date();
36
+ const yesterday = new Date(today);
37
+ yesterday.setDate(yesterday.getDate() - 1);
38
+
39
+ // Check if it's today
40
+ if (date.toDateString() === today.toDateString()) {
41
+ return `Today, ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
42
+ }
43
+
44
+ // Check if it's yesterday
45
+ if (date.toDateString() === yesterday.toDateString()) {
46
+ return `Yesterday, ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
47
+ }
48
+
49
+ // Otherwise show full date
50
+ return date.toLocaleDateString([], {
51
+ month: 'short',
52
+ day: 'numeric',
53
+ hour: '2-digit',
54
+ minute: '2-digit'
55
+ });
56
+ };
57
+
58
+ return (
59
+ <div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 overflow-y-auto p-4">
60
+ <div
61
+ ref={modalContentRef}
62
+ className="bg-white p-4 rounded-xl shadow-medium max-w-5xl w-full mx-auto my-4 max-h-[85vh] flex flex-col border border-gray-200"
63
+ >
64
+ <div className="flex items-center justify-between mb-4 px-1">
65
+ <h2 className="text-xl font-medium text-gray-800" style={{ fontFamily: "'Google Sans', sans-serif" }}>Drawing History</h2>
66
+ <button
67
+ type="button"
68
+ onClick={onClose}
69
+ className="text-gray-500 hover:text-gray-700 p-2 rounded-full hover:bg-gray-50 transition-colors"
70
+ aria-label="Close"
71
+ >
72
+ <X className="w-5 h-5" />
73
+ </button>
74
+ </div>
75
+
76
+ {!history || history.length === 0 ? (
77
+ <div className="flex-1 flex items-center justify-center text-gray-500 py-12">
78
+ No history available yet. Start drawing to create some!
79
+ </div>
80
+ ) : (
81
+ <div className="flex-1 overflow-y-auto grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 px-1 pb-6">
82
+ {history.map((item, index) => (
83
+ <div key={item.timestamp} className="flex flex-col justify-center h-full">
84
+ <button
85
+ className="w-full h-auto relative group cursor-pointer rounded-lg overflow-hidden border border-gray-200 hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-300"
86
+ onClick={() => onSelectImage(item)}
87
+ type="button"
88
+ aria-label={`Select drawing from ${new Date(item.timestamp).toLocaleString()}`}
89
+ >
90
+ <div className="w-full" style={{ aspectRatio: `${item.dimensions?.width || 1} / ${item.dimensions?.height || 1}` }}>
91
+ <div className="relative bg-black w-full h-full">
92
+ <img
93
+ src={item.imageUrl}
94
+ alt={`Drawing history ${index + 1}`}
95
+ className="w-full h-full object-contain"
96
+ />
97
+ </div>
98
+ <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-black/30 backdrop-blur-[2px] text-xs py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity border-t border-gray-800/10 flex items-center gap-1.5">
99
+ <Calendar className="w-3 h-3 text-gray-300" />
100
+ <span className="text-white/90">{formatDate(item.timestamp)}</span>
101
+ </div>
102
+ </div>
103
+ </button>
104
+ </div>
105
+ ))}
106
+ </div>
107
+ )}
108
+ </div>
109
+ </div>
110
+ );
111
+ };
112
+
113
+ export default HistoryModal;
components/ImageRefiner.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { Send } from 'lucide-react';
3
+
4
+ const ImageRefiner = ({
5
+ onRefine,
6
+ isLoading,
7
+ hasGeneratedContent
8
+ }) => {
9
+ const [inputValue, setInputValue] = useState('');
10
+
11
+ const handleSubmit = (e) => {
12
+ e.preventDefault();
13
+ if (!inputValue.trim()) return;
14
+ onRefine(inputValue);
15
+ setInputValue('');
16
+ };
17
+
18
+ if (!hasGeneratedContent) return null;
19
+
20
+ return (
21
+ <form onSubmit={handleSubmit} className="flex gap-2">
22
+ <input
23
+ type="text"
24
+ value={inputValue}
25
+ onChange={(e) => setInputValue(e.target.value)}
26
+ placeholder="Type to refine the image..."
27
+ disabled={isLoading}
28
+ className="flex-1 px-4 py-2 bg-white border border-gray-200 rounded-xl text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:opacity-50 disabled:cursor-not-allowed"
29
+ />
30
+ <button
31
+ type="submit"
32
+ disabled={isLoading || !inputValue.trim()}
33
+ className="p-2 bg-gray-900 text-white rounded-xl hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
34
+ >
35
+ <Send className="w-5 h-5" />
36
+ </button>
37
+ </form>
38
+ );
39
+ };
40
+
41
+ export default ImageRefiner;
components/LibraryPage.js ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { X, ArrowLeft, Wand2 } from 'lucide-react';
3
+ import Masonry from 'react-masonry-css';
4
+
5
+ // Function to get last modified date from filename for sorting
6
+ const getDateFromFilename = (filename) => {
7
+ // Extract date if it's in the format "chrome-study - YYYY-MM-DDTHHMMSS"
8
+ const dateMatch = filename.match(/chrome-study - (\d{4}-\d{2}-\d{2}T\d{6})/);
9
+ if (dateMatch) {
10
+ return new Date(dateMatch[1].replace('T', 'T').slice(0, 19));
11
+ }
12
+
13
+ // Extract number if it's in the format "chrome-study (XX)" or "chrome-study-XX"
14
+ const numMatch = filename.match(/chrome-study[- ]\(?(\d+)/);
15
+ if (numMatch) {
16
+ return Number.parseInt(numMatch[1], 10);
17
+ }
18
+
19
+ return 0; // Default value for sorting
20
+ };
21
+
22
+ const LibraryPage = ({ onBack, onUseAsTemplate }) => {
23
+ const [images, setImages] = useState([]);
24
+ const [fullscreenImage, setFullscreenImage] = useState(null);
25
+ const [isLoading, setIsLoading] = useState(true);
26
+ const [hoveredImage, setHoveredImage] = useState(null);
27
+
28
+ // Breakpoint columns configuration for the masonry layout
29
+ const breakpointColumnsObj = {
30
+ default: 4,
31
+ 1100: 3,
32
+ 700: 2,
33
+ 500: 1
34
+ };
35
+
36
+ useEffect(() => {
37
+ // Function to get all images from the library folder
38
+ const fetchImages = async () => {
39
+ try {
40
+ // Simulate fetching the list of images
41
+ // In a real app, you would fetch this from an API
42
+ const imageFiles = [
43
+ "chrome-study (17).png",
44
+ "chrome-study (19).png",
45
+ "chrome-study (27).png",
46
+ "chrome-study (43).png",
47
+ "chrome-study (47).png",
48
+ "chrome-study (48).png",
49
+ "chrome-study (55).png",
50
+ "chrome-study (56).png",
51
+ "chrome-study (58).png",
52
+ "chrome-study (62).png",
53
+ "chrome-study (64).png",
54
+ "chrome-study (72).png",
55
+ "chrome-study (76).png",
56
+ "chrome-study (77).png",
57
+ "chrome-study (78).png",
58
+ "chrome-study (79).png",
59
+ "chrome-study (81).png",
60
+ "chrome-study (83).png",
61
+ "chrome-study (84).png",
62
+ "chrome-study (86).png",
63
+ "chrome-study (87).png",
64
+ "chrome-study (92).png",
65
+ "chrome-study (94).png",
66
+ "chrome-study (95).png",
67
+ "chrome-study (98).png",
68
+ "chrome-study (99).png",
69
+ "chrome-study - 2025-03-29T231111.407.png",
70
+ "chrome-study - 2025-03-29T231628.676.png",
71
+ "chrome-study - 2025-03-29T231852.687.png",
72
+ "chrome-study - 2025-03-29T232157.263.png",
73
+ "chrome-study - 2025-03-29T232601.690.png",
74
+ "chrome-study - 2025-03-29T235802.886.png",
75
+ "chrome-study - 2025-03-30T000256.137.png",
76
+ "chrome-study - 2025-03-30T000847.148.png",
77
+ "chrome-study - 2025-03-30T001126.978.png",
78
+ "chrome-study - 2025-03-30T001518.410.png",
79
+ "chrome-study - 2025-03-30T002129.834.png",
80
+ "chrome-study - 2025-03-30T002928.187.png",
81
+ "chrome-study - 2025-03-30T003503.053.png",
82
+ "chrome-study - 2025-03-30T003713.255.png",
83
+ "chrome-study - 2025-03-30T003942.300.png",
84
+ "chrome-study - 2025-03-30T011127.402.png",
85
+ "chrome-study-11.png",
86
+ "chrome-study-6.png"
87
+ ];
88
+
89
+ // Sort images by "newest" (using filename to guess date/order)
90
+ // In a real app, you would have actual metadata
91
+ const sortedImages = imageFiles.sort((a, b) => {
92
+ const dateA = getDateFromFilename(a);
93
+ const dateB = getDateFromFilename(b);
94
+ return dateB - dateA; // Descending order
95
+ });
96
+
97
+ setImages(sortedImages);
98
+ setIsLoading(false);
99
+ } catch (error) {
100
+ console.error("Error fetching library images:", error);
101
+ setIsLoading(false);
102
+ }
103
+ };
104
+
105
+ fetchImages();
106
+ }, []);
107
+
108
+ const handleImageClick = (imagePath) => {
109
+ setFullscreenImage(imagePath);
110
+ };
111
+
112
+ const handleKeyDown = (event, imagePath) => {
113
+ if (event.key === 'Enter' || event.key === ' ') {
114
+ setFullscreenImage(imagePath);
115
+ }
116
+ };
117
+
118
+ return (
119
+ <div className="flex min-h-screen flex-col items-center justify-start bg-gray-50 p-2 md:p-4 overflow-y-auto">
120
+ <div className="w-full max-w-[1800px] mx-auto pb-32">
121
+ {/* Fixed header section */}
122
+ <div className="fixed top-0 left-0 right-0 bg-gray-50 z-10 px-2 md:px-4 pt-2 md:pt-4 pb-3">
123
+ <div className="w-full max-w-[1800px] mx-auto">
124
+ {/* Simple Header */}
125
+ <div className="flex items-center justify-between mt-4 mx-1">
126
+ <button
127
+ type="button"
128
+ onClick={onBack}
129
+ className="flex items-center text-gray-800 hover:text-gray-600 hover:cursor-pointer transition-colors text-lg font-medium"
130
+ aria-label="Go back to gallery"
131
+ >
132
+ <ArrowLeft className="w-5 h-5 mr-1" />
133
+ Gallery
134
+ </button>
135
+
136
+ <div>
137
+ <span className="inline-flex items-center rounded-full border px-5 py-2 border-gray-200 bg-gray-100 text-base text-gray-500">
138
+ Submit by replying to this{" "}<a href="https://x.com/dev_valladares/status/1799888888888888888" target="_blank" rel="noreferrer" className="underline ml-1">tweet</a>
139
+ </span>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ {/* Content with padding to account for fixed header */}
146
+ <div className="space-y-4 mt-28">
147
+ {/* Loading state */}
148
+ {isLoading && (
149
+ <div className="flex items-center justify-center h-64">
150
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-400" />
151
+ </div>
152
+ )}
153
+
154
+ {/* Masonry grid of images */}
155
+ <Masonry
156
+ breakpointCols={breakpointColumnsObj}
157
+ className="flex w-auto -ml-4"
158
+ columnClassName="pl-4 bg-clip-padding"
159
+ >
160
+ {images.map((image, index) => (
161
+ <button
162
+ key={image}
163
+ className="mb-4 cursor-pointer transform transition-transform hover:scale-[1.01] text-left block w-full p-0 border-0 bg-transparent"
164
+ onClick={() => handleImageClick(`/library/${image}`)}
165
+ onMouseEnter={() => setHoveredImage(image)}
166
+ onMouseLeave={() => setHoveredImage(null)}
167
+ type="button"
168
+ aria-label={`Screenshot ${index + 1}`}
169
+ >
170
+ <div className="relative rounded-xl overflow-hidden border border-gray-200 shadow-sm bg-white">
171
+ <img
172
+ src={`/library/${image}`}
173
+ alt={`Screenshot ${index + 1}`}
174
+ className="w-full h-auto object-cover"
175
+ loading="lazy"
176
+ />
177
+
178
+ {/* Use as template button */}
179
+ {hoveredImage === image && onUseAsTemplate && (
180
+ <div className="absolute bottom-2 right-2 z-10">
181
+ <button
182
+ onClick={(e) => {
183
+ e.stopPropagation(); // Prevent opening the fullscreen view
184
+ onUseAsTemplate(`/library/${image}`);
185
+ }}
186
+ className="flex items-center gap-1 bg-white/90 hover:bg-white text-gray-800 px-3 py-1.5 rounded-full text-xs font-medium shadow-md transition-all"
187
+ type="button"
188
+ >
189
+ <Wand2 className="w-3 h-3" />
190
+ Use as template
191
+ </button>
192
+ </div>
193
+ )}
194
+ </div>
195
+ </button>
196
+ ))}
197
+ </Masonry>
198
+
199
+ {/* No images state */}
200
+ {!isLoading && images.length === 0 && (
201
+ <div className="flex flex-col items-center justify-center h-64 text-gray-500">
202
+ <p className="text-lg mb-2">No images in library</p>
203
+ <p className="text-sm">Create some images to see them here</p>
204
+ </div>
205
+ )}
206
+ </div>
207
+ </div>
208
+
209
+ {/* Fullscreen image modal */}
210
+ {fullscreenImage && (
211
+ <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
212
+ <div className="relative max-w-4xl w-full max-h-[90vh]">
213
+ <button
214
+ type="button"
215
+ onClick={() => setFullscreenImage(null)}
216
+ className="absolute -top-12 right-0 p-2 text-white hover:text-gray-300 transition-colors"
217
+ aria-label="Close fullscreen view"
218
+ >
219
+ <X className="w-6 h-6" />
220
+ </button>
221
+ <img
222
+ src={fullscreenImage}
223
+ alt="Fullscreen view"
224
+ className="w-full h-auto object-contain max-h-[90vh] rounded-lg"
225
+ />
226
+ </div>
227
+ </div>
228
+ )}
229
+ </div>
230
+ );
231
+ };
232
+
233
+ export default LibraryPage;
components/MaterialLibrary.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Plus } from 'lucide-react';
2
+
3
+ // Define the type for material object
4
+ interface Material {
5
+ name: string;
6
+ file: string;
7
+ prompt: string;
8
+ }
9
+
10
+ interface MaterialLibraryProps {
11
+ onSelectMaterial: (material: Material) => void;
12
+ }
13
+
14
+ // Only keep the Topographic material from the actual materials bar
15
+ const libraryMaterials: Record<string, Material> = {
16
+ topographic: {
17
+ name: "Topographic",
18
+ file: "topographic.jpeg",
19
+ prompt: "Transform this sketch into a sculptural form composed of precisely stacked, thin metallic rings or layers. Render it with warm copper/bronze tones with each layer maintaining equal spacing from adjacent layers, creating a topographic map effect. The form should appear to flow and undulate while maintaining the precise parallel structure. Use dramatic studio lighting against a pure black background to highlight the metallic luster and dimensional quality. Render it in a high-end 3D visualization style with perfect definition between each ring layer."
20
+ }
21
+ };
22
+
23
+ const MaterialLibrary: React.FC<MaterialLibraryProps> = ({ onSelectMaterial }) => {
24
+ return (
25
+ <div className="bg-white p-6 rounded-xl shadow-medium w-full">
26
+ <div className="flex items-center justify-between mb-6">
27
+ <h2 className="text-xl font-medium text-gray-800" style={{ fontFamily: "'Google Sans Text', sans-serif" }}>Material Library</h2>
28
+ <button
29
+ className="text-blue-500 bg-blue-50 hover:bg-blue-100 px-3 py-1 rounded-full text-sm font-medium"
30
+ >
31
+ +Add your own Material
32
+ </button>
33
+ </div>
34
+
35
+ <div className="grid grid-cols-3 gap-3">
36
+ {Object.entries(libraryMaterials).map(([key, material]) => (
37
+ <div key={key} className="relative group">
38
+ <div className="border border-gray-200 overflow-hidden rounded-lg bg-white">
39
+ <div className="w-full relative" style={{ aspectRatio: '1/1' }}>
40
+ <img
41
+ src={`/samples/${material.file}`}
42
+ alt={`${material.name} material`}
43
+ className="w-full h-full object-cover"
44
+ />
45
+ <button
46
+ onClick={() => onSelectMaterial(material)}
47
+ className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 flex items-center justify-center transition-all duration-200"
48
+ >
49
+ <div className="opacity-0 group-hover:opacity-100 bg-white rounded-full p-1 transform translate-y-2 group-hover:translate-y-0 transition-all duration-200">
50
+ <Plus className="w-5 h-5 text-blue-500" />
51
+ </div>
52
+ </button>
53
+ </div>
54
+ <div className="px-2 py-1 text-center text-sm font-medium border-t border-gray-200">
55
+ {material.name}
56
+ </div>
57
+ </div>
58
+ </div>
59
+ ))}
60
+ </div>
61
+ </div>
62
+ );
63
+ };
64
+
65
+ export default MaterialLibrary;
components/OutputOptionsBar.js ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Brush, Download, History, RefreshCw } from 'lucide-react';
2
+ import { useState } from 'react';
3
+
4
+ // Mini component for consistent button styling
5
+ const ActionButton = ({ icon: Icon, label, onClick, disabled, ariaLabel, onMouseEnter, onMouseLeave }) => {
6
+ return (
7
+ <button
8
+ type="button"
9
+ onClick={onClick}
10
+ disabled={disabled}
11
+ className="focus:outline-none group relative flex-shrink-0"
12
+ aria-label={ariaLabel}
13
+ onMouseEnter={onMouseEnter}
14
+ onMouseLeave={onMouseLeave}
15
+ >
16
+ <div className={`w-16 md:w-14 border overflow-hidden rounded-lg ${
17
+ disabled
18
+ ? 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed'
19
+ : 'bg-gray-50 border-gray-200 group-hover:bg-white group-hover:border-gray-300 transition-colors'
20
+ }`}>
21
+ {/* Icon container */}
22
+ <div className="w-full relative flex items-center justify-center py-3 md:py-2">
23
+ <Icon className={`w-5 h-5 ${
24
+ disabled
25
+ ? 'text-gray-400'
26
+ : 'text-gray-400 group-hover:text-gray-700 transition-colors'
27
+ }`} />
28
+ </div>
29
+ </div>
30
+ </button>
31
+ );
32
+ };
33
+
34
+ const OutputOptionsBar = ({
35
+ isLoading,
36
+ hasGeneratedContent,
37
+ onSendToDoodle,
38
+ }) => {
39
+ const [showDoodleTooltip, setShowDoodleTooltip] = useState(false);
40
+
41
+ return (
42
+ <div className="flex items-center gap-2">
43
+
44
+ {/* "Send to Doodle" button with tooltip */}
45
+ <div className="relative">
46
+ <ActionButton
47
+ icon={Brush}
48
+ label="Doodle"
49
+ onClick={onSendToDoodle}
50
+ disabled={isLoading || !hasGeneratedContent}
51
+ ariaLabel="Send image back to doodle canvas"
52
+ onMouseEnter={() => setShowDoodleTooltip(true)}
53
+ onMouseLeave={() => setShowDoodleTooltip(false)}
54
+ />
55
+ {/* Custom Tooltip - Right aligned */}
56
+ {showDoodleTooltip && (
57
+ <div className="absolute bottom-full right-0 mb-2 px-3 py-1.5 bg-white border border-gray-200 text-gray-700 text-xs rounded-lg shadow-soft whitespace-nowrap pointer-events-none z-10">
58
+ Send to Doodle Canvas
59
+ </div>
60
+ )}
61
+ </div>
62
+
63
+ </div>
64
+ );
65
+ };
66
+
67
+ export default OutputOptionsBar;
components/StyleSelector.js ADDED
@@ -0,0 +1,1571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { RefreshCw, Plus, Upload, Edit, Trash2, RefreshCcw, HelpCircle, Sparkles, Info, Check, X } from 'lucide-react';
3
+ import AddMaterialModal from './AddMaterialModal.js';
4
+ import { createPortal } from 'react-dom';
5
+
6
+ // Add custom scrollbar hiding styles
7
+ const scrollbarHideStyles = `
8
+ /* Hide scrollbar for Chrome, Safari and Opera */
9
+ .scrollbar-hide::-webkit-scrollbar {
10
+ display: none;
11
+ }
12
+
13
+ /* Hide scrollbar for IE, Edge and Firefox */
14
+ .scrollbar-hide {
15
+ -ms-overflow-style: none; /* IE and Edge */
16
+ scrollbar-width: none; /* Firefox */
17
+ }
18
+ `;
19
+
20
+ // Define default style options with display names and prompts
21
+ const defaultStyleOptions = {
22
+ material: {
23
+ name: "Chrome",
24
+ file: "chrome.jpeg",
25
+ prompt: "Recreate this doodle as a physical, floating chrome sculpture made of a chromium metal tubes or pipes in a professional studio setting. If it is typography, render it accordingly, but always always have a black background and studio lighting. Render it using Cinema 4D with Octane, using studio lighting against a pure black background. Make it look like a high-end elegant rendering of a sculptural piece. Flat Black background always"
26
+ },
27
+ honey: {
28
+ name: "Honey",
29
+ file: "honey.jpeg",
30
+ prompt: "Transform this sketch into a honey. Render it as if made entirely of translucent, golden honey with characteristic viscous drips and flows. Add realistic liquid properties including surface tension, reflections, and light refraction. Render it in Cinema 4D with Octane, using studio lighting against a pure black background. Flat Black background always"
31
+ },
32
+ softbody: {
33
+ name: "Soft Body",
34
+ file: "softbody.jpeg",
35
+ prompt: "Convert this drawing / text into a soft body physics render. Render it as if made of a soft, jelly-like material that responds to gravity and motion. Add realistic deformation, bounce, and squash effects typical of soft body dynamics. Use dramatic lighting against a black background to emphasize the material's translucency and surface properties. Render it in Cinema 4D with Octane, using studio lighting against a pure black background. Make it look like a high-end 3D animation frame."
36
+ },
37
+ testMaterial: {
38
+ name: "Surprise Me!",
39
+ file: "test-material.jpeg",
40
+ prompt: "Transform this sketch into an experimental material with unique and unexpected properties. Each generation should be different and surprising - it could be crystalline, liquid, gaseous, organic, metallic, or something completely unexpected. Use dramatic studio lighting against a pure black background to showcase the material's unique characteristics. Render it in a high-end 3D style with professional lighting and composition, emphasizing the most interesting and unexpected qualities of the chosen material."
41
+ }
42
+ };
43
+
44
+ // Create a mutable copy that will include user-added materials
45
+ export let styleOptions = { ...defaultStyleOptions };
46
+
47
+ // Define the base prompt template
48
+ const BASE_PROMPT = (materialName) =>
49
+ `Transform this sketch into a ${materialName.toLowerCase()} material. Render it in a high-end 3D visualization style with professional studio lighting against a pure black background. Make it look like an elegant Cinema 4D and Octane rendering with detailed material properties and characteristics. The final result should be an elegant visualization with perfect studio lighting, crisp shadows, and high-end material definition.`;
50
+
51
+ // Function to add a material directly to the library
52
+ export const addMaterialToLibrary = (material) => {
53
+ // Create a unique key for the material based on its name and timestamp
54
+ const materialKey = `${material.name.toLowerCase().replace(/\s+/g, '_')}_${Date.now()}`;
55
+
56
+ // Create the material object
57
+ const newMaterial = {
58
+ name: material.name,
59
+ prompt: material.prompt || BASE_PROMPT(material.name),
60
+ thumbnail: material.image || material.thumbnail,
61
+ originalDescription: material.name,
62
+ isCustom: true
63
+ };
64
+
65
+ // Add to styleOptions
66
+ styleOptions[materialKey] = newMaterial;
67
+
68
+ // Save to localStorage
69
+ try {
70
+ const savedMaterials = localStorage.getItem('customMaterials') || '{}';
71
+ const customMaterials = JSON.parse(savedMaterials);
72
+ customMaterials[materialKey] = newMaterial;
73
+ localStorage.setItem('customMaterials', JSON.stringify(customMaterials));
74
+ } catch (error) {
75
+ console.error('Error saving material to localStorage:', error);
76
+ }
77
+
78
+ // Return the key so it can be selected
79
+ return materialKey;
80
+ };
81
+
82
+ // --- Updated function to use /api/enhance-prompt ---
83
+ const enhanceMaterialDetails = async (materialDescription) => {
84
+ console.log("Enhancing prompt for:", materialDescription);
85
+ const basePrompt = BASE_PROMPT(materialDescription); // Generate the base prompt to send
86
+
87
+ try {
88
+ const response = await fetch("/api/enhance-prompt", { // Call the correct API endpoint
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify({
92
+ materialName: materialDescription, // Send the user's input as materialName
93
+ basePrompt: basePrompt // Send the generated base prompt
94
+ })
95
+ });
96
+
97
+ if (!response.ok) {
98
+ // Handle API errors (like 500)
99
+ throw new Error(`API responded with status ${response.status}`);
100
+ }
101
+
102
+ const data = await response.json();
103
+ console.log("Enhanced prompt data:", data);
104
+
105
+ // Use the response from /api/enhance-prompt
106
+ // The API itself has fallback logic if JSON parsing fails,
107
+ // returning { enhancedPrompt: basePrompt, suggestedName: materialName }
108
+ if (data.enhancedPrompt && data.suggestedName) {
109
+ return {
110
+ name: data.suggestedName,
111
+ prompt: data.enhancedPrompt
112
+ };
113
+ } else {
114
+ // This case might occur if the API returns unexpected JSON structure
115
+ throw new Error('Invalid enhancement data received from /api/enhance-prompt');
116
+ }
117
+
118
+ } catch (error) {
119
+ console.error("Error enhancing prompt:", error);
120
+
121
+ // Fallback if the fetch fails or response is totally invalid
122
+ const capitalizedName = materialDescription
123
+ .split(' ')
124
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
125
+ .join(' ');
126
+
127
+ return {
128
+ name: `${capitalizedName} Style`, // Slightly different fallback name
129
+ prompt: basePrompt // Use the original base prompt as fallback
130
+ };
131
+ }
132
+ };
133
+ // --- End of updated function ---
134
+
135
+ // Function to get prompt based on style mode
136
+ export const getPromptForStyle = (styleMode) => {
137
+ if (!styleMode || !styleOptions[styleMode]) {
138
+ return styleOptions.material.prompt;
139
+ }
140
+ return styleOptions[styleMode].prompt || styleOptions.material.prompt;
141
+ };
142
+
143
+ // Replace with a simpler generatePromptForMaterial function
144
+ export const generatePromptForMaterial = (materialName) => {
145
+ return `Transform this sketch into a ${materialName.toLowerCase()} material. Render it in a high-end 3D visualization style with professional studio lighting against a pure black background. Make it look like a elegant Cinema 4D and octane rendering with detailed material properties and characteristics.`;
146
+ };
147
+
148
+ const StyleSelector = ({ styleMode, setStyleMode, handleGenerate }) => {
149
+ const [showAddMaterialModal, setShowAddMaterialModal] = useState(false);
150
+ const [newMaterialName, setNewMaterialName] = useState('');
151
+ const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false);
152
+ const [useCustomImage, setUseCustomImage] = useState(false);
153
+ const [customImagePreview, setCustomImagePreview] = useState('');
154
+ const [customImageFile, setCustomImageFile] = useState(null);
155
+ const fileInputRef = useRef(null);
156
+ const [recentlyAdded, setRecentlyAdded] = useState(null);
157
+ const [customPrompt, setCustomPrompt] = useState('');
158
+ const [showCustomPrompt, setShowCustomPrompt] = useState(false);
159
+ const [previewThumbnail, setPreviewThumbnail] = useState('');
160
+ const [isGeneratingPreview, setIsGeneratingPreview] = useState(false);
161
+ const [materials, setMaterials] = useState(defaultStyleOptions);
162
+ const [generatedMaterialName, setGeneratedMaterialName] = useState('');
163
+ const [generatedPrompt, setGeneratedPrompt] = useState('');
164
+ const [isGeneratingText, setIsGeneratingText] = useState(false);
165
+ const [showMaterialNameEdit, setShowMaterialNameEdit] = useState(false);
166
+ const [isGenerating, setIsGenerating] = useState(false);
167
+ const [showPromptInfo, setShowPromptInfo] = useState(null);
168
+ const [promptPopoverPosition, setPromptPopoverPosition] = useState({ top: 0, left: 0 });
169
+ const styleSelectorRef = useRef(null);
170
+ const [editingPrompt, setEditingPrompt] = useState(null);
171
+ const [editedPromptText, setEditedPromptText] = useState('');
172
+ const [hasPromptChanged, setHasPromptChanged] = useState(false);
173
+ const [generatedThumbnail, setGeneratedThumbnail] = useState(null);
174
+ const [thumbnailError, setThumbnailError] = useState(null);
175
+
176
+ // Load custom materials from local storage on component mount
177
+ useEffect(() => {
178
+ loadCustomMaterials();
179
+ }, []);
180
+
181
+ // Add effect to update materials state when styleOptions changes
182
+ useEffect(() => {
183
+ // This ensures the UI reflects changes to styleOptions made from outside this component
184
+ setMaterials({...styleOptions});
185
+ }, [styleOptions]);
186
+
187
+ // Extract loadCustomMaterials into its own named function
188
+ const loadCustomMaterials = () => {
189
+ try {
190
+ const savedMaterials = localStorage.getItem('customMaterials');
191
+ if (savedMaterials) {
192
+ const parsedMaterials = JSON.parse(savedMaterials);
193
+ // Update both the styleOptions and the state
194
+ const updatedMaterials = { ...defaultStyleOptions, ...parsedMaterials };
195
+ styleOptions = updatedMaterials;
196
+ setMaterials(updatedMaterials);
197
+ console.log('Loaded custom materials from local storage');
198
+ }
199
+ } catch (error) {
200
+ console.error('Error loading custom materials:', error);
201
+ }
202
+ };
203
+
204
+ // Modify the useEffect that handles material descriptions
205
+ useEffect(() => {
206
+ const delayedGeneration = async () => {
207
+ // Skip if no material name or if we're in edit mode
208
+ if (!newMaterialName.trim() || recentlyAdded) return;
209
+
210
+ // ONLY set text generation loading state
211
+ setIsGeneratingText(true);
212
+
213
+ try {
214
+ // Use our updated enhanceMaterialDetails function (which now calls /api/enhance-prompt)
215
+ const enhanced = await enhanceMaterialDetails(newMaterialName);
216
+ console.log("Received enhanced data in useEffect:", enhanced);
217
+ setGeneratedMaterialName(enhanced.name);
218
+ setGeneratedPrompt(enhanced.prompt);
219
+
220
+ // DO NOT generate thumbnail here
221
+ } catch (error) {
222
+ console.error('Error in material generation (useEffect):', error);
223
+
224
+ // Fall back to basic generation if enhanceMaterialDetails fails catastrophically
225
+ // (This shouldn't happen often as enhanceMaterialDetails has its own internal fallback)
226
+ const capitalizedName = newMaterialName
227
+ .split(' ')
228
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
229
+ .join(' ');
230
+ setGeneratedMaterialName(`${capitalizedName} Style`);
231
+ setGeneratedPrompt(BASE_PROMPT(newMaterialName));
232
+ } finally {
233
+ setIsGeneratingText(false);
234
+ }
235
+ };
236
+
237
+ // Delay generation to avoid too many API calls while typing
238
+ const timeoutId = setTimeout(delayedGeneration, 1500);
239
+ return () => clearTimeout(timeoutId);
240
+ }, [newMaterialName, recentlyAdded]); // Dependencies remain the same
241
+
242
+ // Helper function to resize and compress image data
243
+ const compressImage = (dataUrl, maxWidth = 200) => {
244
+ return new Promise((resolve) => {
245
+ const img = new Image();
246
+ img.onload = () => {
247
+ // Create a canvas to resize the image
248
+ const canvas = document.createElement('canvas');
249
+ const ctx = canvas.getContext('2d');
250
+
251
+ // Calculate new dimensions
252
+ let width = img.width;
253
+ let height = img.height;
254
+
255
+ if (width > maxWidth) {
256
+ height = (height * maxWidth) / width;
257
+ width = maxWidth;
258
+ }
259
+
260
+ canvas.width = width;
261
+ canvas.height = height;
262
+
263
+ // Draw and export as JPEG with lower quality
264
+ ctx.drawImage(img, 0, 0, width, height);
265
+ resolve(canvas.toDataURL('image/jpeg', 0.7));
266
+ };
267
+ img.src = dataUrl;
268
+ });
269
+ };
270
+
271
+ // Function to debug storage usage
272
+ const checkStorageUsage = () => {
273
+ let totalSize = 0;
274
+ let itemCount = 0;
275
+
276
+ for (let i = 0; i < localStorage.length; i++) {
277
+ const key = localStorage.key(i);
278
+ const value = localStorage.getItem(key);
279
+ const size = (key.length + value.length) * 2; // Approximate size in bytes (UTF-16)
280
+ totalSize += size;
281
+ itemCount++;
282
+ console.log(`Item: ${key}, Size: ${(size / 1024).toFixed(2)}KB`);
283
+ }
284
+
285
+ console.log(`Total localStorage usage: ${(totalSize / 1024 / 1024).toFixed(2)}MB, Items: ${itemCount}`);
286
+ return totalSize;
287
+ };
288
+
289
+ const handleAddMaterial = () => {
290
+ resetMaterialForm();
291
+ setRecentlyAdded(null);
292
+ setShowAddMaterialModal(true);
293
+ };
294
+
295
+ const handleCloseModal = () => {
296
+ setShowAddMaterialModal(false);
297
+ setNewMaterialName('');
298
+ setUseCustomImage(false);
299
+ setCustomImagePreview('');
300
+ setCustomImageFile(null);
301
+ setCustomPrompt('');
302
+ setShowCustomPrompt(false);
303
+ setPreviewThumbnail('');
304
+ };
305
+
306
+ // Handle clicking outside of the modal to close it
307
+ const handleClickOutsideModal = (e) => {
308
+ // If the clicked element is the backdrop (has the modalBackdrop class)
309
+ if (e.target.classList.contains('modalBackdrop')) {
310
+ handleCloseModal();
311
+ }
312
+ };
313
+
314
+ const handleFileChange = (e) => {
315
+ const file = e.target.files[0];
316
+ if (!file) return;
317
+
318
+ if (!file.type.startsWith('image/')) {
319
+ alert('Please select an image file');
320
+ return;
321
+ }
322
+
323
+ const reader = new FileReader();
324
+ reader.onload = () => {
325
+ // When the file is loaded, create a temporary image to extract a square crop
326
+ const img = new Image();
327
+ img.onload = () => {
328
+ // Create a canvas element to crop the image to a square
329
+ const canvas = document.createElement('canvas');
330
+ // Determine the size of the square (min of width and height)
331
+ const size = Math.min(img.width, img.height);
332
+ canvas.width = size;
333
+ canvas.height = size;
334
+
335
+ // Calculate the position to start drawing to center the crop
336
+ const offsetX = (img.width - size) / 2;
337
+ const offsetY = (img.height - size) / 2;
338
+
339
+ // Draw the cropped image to the canvas
340
+ const ctx = canvas.getContext('2d');
341
+ ctx.drawImage(img, offsetX, offsetY, size, size, 0, 0, size, size);
342
+
343
+ // Convert the canvas to a data URL
344
+ const croppedImageDataUrl = canvas.toDataURL(file.type);
345
+ setCustomImagePreview(croppedImageDataUrl);
346
+ setCustomImageFile(file);
347
+ };
348
+ img.src = reader.result;
349
+ };
350
+ reader.readAsDataURL(file);
351
+ };
352
+
353
+ const triggerFileInput = () => {
354
+ fileInputRef.current.click();
355
+ };
356
+
357
+ const handleGenerateDefaultPrompt = () => {
358
+ if (!newMaterialName.trim()) return;
359
+
360
+ // Generate default prompt based on material name
361
+ const defaultPrompt = generatePromptForMaterial(newMaterialName);
362
+ setCustomPrompt(defaultPrompt);
363
+
364
+ // Clear the preview so it will regenerate with the new prompt
365
+ setPreviewThumbnail('');
366
+ };
367
+
368
+ // Add a helper function to read file as data URL
369
+ const readFileAsDataURL = (file) => {
370
+ return new Promise((resolve, reject) => {
371
+ const reader = new FileReader();
372
+ reader.onload = () => resolve(reader.result);
373
+ reader.onerror = reject;
374
+ reader.readAsDataURL(file);
375
+ });
376
+ };
377
+
378
+ // Option 1: Add function to compress images more aggressively before storage
379
+ const compressImageForStorage = async (dataUrl) => {
380
+ // Use a smaller max width for storage
381
+ const maxWidth = 100; // Reduce from 200 to 100
382
+ const quality = 0.5; // Reduce quality from 0.7 to 0.5
383
+
384
+ return new Promise((resolve) => {
385
+ const img = new Image();
386
+ img.onload = () => {
387
+ const canvas = document.createElement('canvas');
388
+ const ctx = canvas.getContext('2d');
389
+
390
+ let width = img.width;
391
+ let height = img.height;
392
+
393
+ if (width > maxWidth) {
394
+ height = (height * maxWidth) / width;
395
+ width = maxWidth;
396
+ }
397
+
398
+ canvas.width = width;
399
+ canvas.height = height;
400
+
401
+ ctx.drawImage(img, 0, 0, width, height);
402
+ resolve(canvas.toDataURL('image/jpeg', quality));
403
+ };
404
+ img.src = dataUrl;
405
+ });
406
+ };
407
+
408
+ // Option 2: Add function to manage storage limits
409
+ const manageStorageLimit = async (newMaterial) => {
410
+ try {
411
+ // Get current materials
412
+ const savedMaterials = localStorage.getItem('customMaterials');
413
+ if (!savedMaterials) return;
414
+
415
+ const parsedMaterials = JSON.parse(savedMaterials);
416
+ const customKeys = Object.keys(parsedMaterials).filter(key =>
417
+ !Object.keys(defaultStyleOptions).includes(key));
418
+
419
+ // If we have too many custom materials, remove the oldest ones
420
+ if (customKeys.length > 4) { // Limit to 5 custom materials
421
+ // Sort by creation time (if you have that data) or just take the first ones
422
+ const keysToRemove = customKeys.slice(0, customKeys.length - 4);
423
+
424
+ keysToRemove.forEach(key => {
425
+ delete parsedMaterials[key];
426
+ });
427
+
428
+ // Save the reduced set back to localStorage
429
+ localStorage.setItem('customMaterials', JSON.stringify(parsedMaterials));
430
+ }
431
+ } catch (error) {
432
+ console.error('Error managing storage limit:', error);
433
+ }
434
+ };
435
+
436
+ // Add a function to reset the form fields
437
+ const resetMaterialForm = () => {
438
+ setNewMaterialName('');
439
+ setGeneratedMaterialName('');
440
+ setGeneratedPrompt('');
441
+ setCustomPrompt('');
442
+ setPreviewThumbnail('');
443
+ setUseCustomImage(false);
444
+ setCustomImagePreview('');
445
+ setShowCustomPrompt(false);
446
+ };
447
+
448
+ // Update the openAddMaterialModal function to reset form on open
449
+ const openAddMaterialModal = () => {
450
+ resetMaterialForm();
451
+ setRecentlyAdded(null);
452
+ setShowAddMaterialModal(true);
453
+ };
454
+
455
+ // Modify handleEditMaterial to handle all material properties
456
+ const handleEditMaterial = (materialId) => {
457
+ const material = materials[materialId];
458
+ if (!material) return;
459
+
460
+ // Set form fields with existing data
461
+ setNewMaterialName(material.originalDescription || material.name); // Fallback to name if no original description
462
+ setGeneratedMaterialName(material.name || '');
463
+
464
+ // Set prompt
465
+ const materialPrompt = material.prompt || '';
466
+ setGeneratedPrompt(materialPrompt);
467
+ setCustomPrompt(materialPrompt);
468
+ setShowCustomPrompt(true); // Show the editable prompt by default
469
+
470
+ // Set thumbnail
471
+ if (material.thumbnail) {
472
+ setPreviewThumbnail(material.thumbnail);
473
+ setUseCustomImage(true); // Mark as custom image to prevent regeneration
474
+ } else if (material.file) {
475
+ setPreviewThumbnail(`/samples/${material.file}`);
476
+ setUseCustomImage(true);
477
+ }
478
+
479
+ // Enable name editing by default
480
+ setShowMaterialNameEdit(true);
481
+
482
+ setRecentlyAdded(materialId);
483
+ setShowAddMaterialModal(true);
484
+ };
485
+
486
+ // Add a function to manually refresh the thumbnail
487
+ const handleRefreshThumbnail = async (prompt) => {
488
+ if (!newMaterialName.trim() || useCustomImage) {
489
+ console.log('Skipping thumbnail refresh: No material name or using custom image');
490
+ return;
491
+ }
492
+
493
+ setIsGeneratingPreview(true);
494
+
495
+ try {
496
+ const promptToUse = showCustomPrompt && customPrompt.trim()
497
+ ? customPrompt
498
+ : generatePromptForMaterial(newMaterialName);
499
+
500
+ // Use the dedicated thumbnail endpoint instead
501
+ const response = await fetch("/api/generate-thumbnail", {
502
+ method: "POST",
503
+ headers: {
504
+ "Content-Type": "application/json",
505
+ },
506
+ body: JSON.stringify({
507
+ prompt: promptToUse,
508
+ referenceImageData: DEFAULT_SHAPE_DATA_URL
509
+ }),
510
+ });
511
+
512
+ if (!response.ok) {
513
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
514
+ }
515
+
516
+ const data = await response.json();
517
+ console.log('Thumbnail API response:', {
518
+ success: data.success,
519
+ hasImageData: !!data.imageData,
520
+ error: data.error
521
+ });
522
+
523
+ if (data.success && data.imageData) {
524
+ // Set the preview thumbnail
525
+ setPreviewThumbnail(`data:image/jpeg;base64,${data.imageData}`);
526
+ console.log('Successfully set new thumbnail');
527
+ } else {
528
+ throw new Error(data.error || 'No image data received');
529
+ }
530
+ } catch (error) {
531
+ console.error("Error generating preview thumbnail:", error);
532
+
533
+ // If we have a previous thumbnail, keep it
534
+ if (previewThumbnail) {
535
+ console.log('Keeping previous thumbnail after error');
536
+ } else {
537
+ // Otherwise generate a fallback
538
+ console.log('Using fallback thumbnail after API error');
539
+ const fallbackThumbnail = createFallbackThumbnail(newMaterialName);
540
+ setPreviewThumbnail(fallbackThumbnail);
541
+ }
542
+
543
+ // Show a brief notification about the error
544
+ const errorToast = document.createElement('div');
545
+ errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50';
546
+ errorToast.innerText = 'Thumbnail generation failed. Using fallback.';
547
+ document.body.appendChild(errorToast);
548
+
549
+ // Remove the notification after 3 seconds
550
+ setTimeout(() => {
551
+ if (document.body.contains(errorToast)) {
552
+ document.body.removeChild(errorToast);
553
+ }
554
+ }, 3000);
555
+
556
+ } finally {
557
+ setIsGeneratingPreview(false);
558
+ }
559
+ };
560
+
561
+ // Add a function to manually refresh the text
562
+ const handleRefreshText = async () => {
563
+ if (!newMaterialName.trim()) return;
564
+
565
+ setIsGeneratingText(true);
566
+
567
+ try {
568
+ // ... existing code for text generation ...
569
+ // This can reuse the same code from the useEffect
570
+ } catch (error) {
571
+ console.error("Error generating material name and prompt:", error);
572
+ } finally {
573
+ setIsGeneratingText(false);
574
+ }
575
+ };
576
+
577
+ // Modify handleNewMaterialDescription to only handle preview generation
578
+ const handleNewMaterialDescription = async (description) => {
579
+ if (!description.trim()) return;
580
+
581
+ setIsGeneratingText(true);
582
+ setIsGeneratingPreview(true);
583
+
584
+ // Keep track of whether we've set a thumbnail yet
585
+ let thumbnailSet = false;
586
+
587
+ try {
588
+ // First, get the enhanced description
589
+ console.log(`Generating enhanced description for: "${description}"`);
590
+ const enhanced = await enhanceMaterialDetails(description);
591
+ setGeneratedMaterialName(enhanced.name);
592
+ setGeneratedPrompt(enhanced.prompt);
593
+
594
+ // Generate thumbnail with the enhanced prompt
595
+ if (!useCustomImage) {
596
+ try {
597
+ console.log(`Generating thumbnail with prompt: "${enhanced.prompt.substring(0, 100)}..."`);
598
+
599
+ // Set a timeout to ensure we don't wait too long for the API
600
+ const thumbnailPromise = fetch("/api/generate", {
601
+ method: "POST",
602
+ headers: {
603
+ "Content-Type": "application/json",
604
+ },
605
+ body: JSON.stringify({
606
+ prompt: enhanced.prompt,
607
+ }),
608
+ });
609
+
610
+ // Add a timeout to the thumbnail generation
611
+ const timeoutPromise = new Promise((_, reject) =>
612
+ setTimeout(() => reject(new Error('Thumbnail generation timed out')), 12000)
613
+ );
614
+
615
+ // Race the thumbnail generation against the timeout
616
+ const response = await Promise.race([thumbnailPromise, timeoutPromise]);
617
+
618
+ if (!response.ok) {
619
+ throw new Error(`API returned status ${response.status}`);
620
+ }
621
+
622
+ const data = await response.json();
623
+
624
+ if (data.success && data.imageData) {
625
+ console.log('Successfully received thumbnail data');
626
+ setPreviewThumbnail(`data:image/jpeg;base64,${data.imageData}`);
627
+ thumbnailSet = true;
628
+ } else {
629
+ throw new Error(data.error || 'No image data received');
630
+ }
631
+ } catch (thumbnailError) {
632
+ console.error("Error generating thumbnail:", thumbnailError);
633
+
634
+ // Fall back to a static thumbnail if we couldn't generate one
635
+ console.log('Using fallback static thumbnail');
636
+
637
+ // Create a colored square as a fallback thumbnail
638
+ const fallbackThumbnail = createFallbackThumbnail(description);
639
+ setPreviewThumbnail(fallbackThumbnail);
640
+ thumbnailSet = true;
641
+ }
642
+ }
643
+ } catch (error) {
644
+ console.error("Error in material generation:", error);
645
+
646
+ // Set fallback values if we failed to generate enhanced content
647
+ const capitalizedName = description
648
+ .split(' ')
649
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
650
+ .join(' ');
651
+
652
+ setGeneratedMaterialName(`${capitalizedName} Material`);
653
+ setGeneratedPrompt(generatePromptForMaterial(description));
654
+
655
+ // If we haven't set a thumbnail yet, create a fallback one
656
+ if (!thumbnailSet && !useCustomImage) {
657
+ console.log('Using fallback static thumbnail after error');
658
+ const fallbackThumbnail = createFallbackThumbnail(description);
659
+ setPreviewThumbnail(fallbackThumbnail);
660
+ }
661
+ } finally {
662
+ setIsGeneratingText(false);
663
+ setIsGeneratingPreview(false);
664
+ }
665
+ };
666
+
667
+ // Add a function to create a fallback thumbnail
668
+ const createFallbackThumbnail = (text) => {
669
+ // Generate a consistent color from the text
670
+ let hash = 0;
671
+ for (let i = 0; i < text.length; i++) {
672
+ hash = text.charCodeAt(i) + ((hash << 5) - hash);
673
+ }
674
+
675
+ // Convert hash to RGB color
676
+ const r = (hash & 0xFF0000) >> 16;
677
+ const g = (hash & 0x00FF00) >> 8;
678
+ const b = hash & 0x0000FF;
679
+
680
+ // Create a small canvas to generate the thumbnail
681
+ const canvas = document.createElement('canvas');
682
+ canvas.width = 100;
683
+ canvas.height = 100;
684
+ const ctx = canvas.getContext('2d');
685
+
686
+ // Create gradient background
687
+ const gradient = ctx.createLinearGradient(0, 0, 100, 100);
688
+ gradient.addColorStop(0, `rgb(${r}, ${g}, ${b})`);
689
+ gradient.addColorStop(1, `rgb(${b}, ${r}, ${g})`);
690
+
691
+ // Fill the background
692
+ ctx.fillStyle = gradient;
693
+ ctx.fillRect(0, 0, 100, 100);
694
+
695
+ // Add text first letter
696
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
697
+ ctx.font = 'bold 48px sans-serif';
698
+ ctx.textAlign = 'center';
699
+ ctx.textBaseline = 'middle';
700
+ ctx.fillText(text.charAt(0).toUpperCase(), 50, 50);
701
+
702
+ // Return as data URL
703
+ return canvas.toDataURL('image/jpeg', 0.9);
704
+ };
705
+
706
+ const handleCreateMaterial = async () => {
707
+ if (!newMaterialName.trim()) return;
708
+
709
+ // Generate a unique ID for the material
710
+ const materialId = recentlyAdded || `custom_${Date.now()}`;
711
+
712
+ // Use the generated material name instead of the description
713
+ const displayName = generatedMaterialName || `${newMaterialName} Material`;
714
+
715
+ // Use the generated or custom prompt
716
+ const materialPrompt = showCustomPrompt ? customPrompt : (generatedPrompt || generatePromptForMaterial(newMaterialName));
717
+
718
+ // Create the new material object
719
+ const newMaterial = {
720
+ name: displayName,
721
+ prompt: materialPrompt,
722
+ thumbnail: useCustomImage ? customImagePreview : previewThumbnail,
723
+ originalDescription: newMaterialName,
724
+ isCustom: true
725
+ };
726
+
727
+ // Update both our state and storage references
728
+ const updatedMaterials = { ...materials, [materialId]: newMaterial };
729
+
730
+ try {
731
+ // Apply compression and save to localStorage
732
+ if (useCustomImage && customImagePreview) {
733
+ newMaterial.thumbnail = await compressImageForStorage(customImagePreview);
734
+ } else if (previewThumbnail) {
735
+ newMaterial.thumbnail = await compressImageForStorage(previewThumbnail);
736
+ }
737
+
738
+ await manageStorageLimit(newMaterial);
739
+ localStorage.setItem('customMaterials', JSON.stringify(updatedMaterials));
740
+
741
+ // Update state and global reference
742
+ styleOptions = updatedMaterials;
743
+ setMaterials(updatedMaterials);
744
+
745
+ // Close the modal
746
+ setShowAddMaterialModal(false);
747
+
748
+ // Reset form
749
+ resetMaterialForm();
750
+
751
+ // Auto-select the newly created material
752
+ setStyleMode(materialId);
753
+
754
+ // Trigger generation with the new material
755
+ if (handleGenerate && typeof handleGenerate === 'function') {
756
+ setTimeout(() => handleGenerate(), 100); // Small delay to ensure styleMode is updated
757
+ }
758
+
759
+ console.log("Material created successfully:", materialId);
760
+ } catch (error) {
761
+ console.error('Storage error:', error);
762
+ alert("Couldn't save your material. Please try clearing some browser data.");
763
+ }
764
+ };
765
+
766
+ const handleDeleteMaterial = (event, key) => {
767
+ event.stopPropagation(); // Prevent triggering the parent button's onClick
768
+
769
+ // Only allow deleting custom materials
770
+ if (styleOptions[key]?.isCustom) {
771
+ if (window.confirm(`Are you sure you want to delete the "${styleOptions[key].name}" material?`)) {
772
+ // If currently selected, switch to default material
773
+ if (styleMode === key) {
774
+ setStyleMode('material');
775
+ }
776
+
777
+ // Delete the material
778
+ const { [key]: deleted, ...remaining } = styleOptions;
779
+ const updatedMaterials = { ...defaultStyleOptions, ...remaining };
780
+ styleOptions = updatedMaterials;
781
+ setMaterials(updatedMaterials);
782
+
783
+ // Save the updated materials
784
+ const customMaterials = {};
785
+ Object.entries(remaining).forEach(([k, v]) => {
786
+ if (!defaultStyleOptions[k]) {
787
+ customMaterials[k] = v;
788
+ }
789
+ });
790
+ localStorage.setItem('customMaterials', JSON.stringify(customMaterials));
791
+ }
792
+ }
793
+ };
794
+
795
+ // Add a function to sort materials in the desired order
796
+ const getSortedMaterials = (materials) => {
797
+ // For mobile view, we'll handle the order in the render function
798
+ // This function now only handles desktop order
799
+
800
+ // 1. Get original materials (excluding Test Material)
801
+ const originalMaterials = Object.entries(defaultStyleOptions)
802
+ .filter(([key]) => key !== 'testMaterial')
803
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
804
+
805
+ // 2. Get custom/locally saved materials (excluding Test Material)
806
+ const customMaterials = Object.entries(materials)
807
+ .filter(([key]) => !defaultStyleOptions[key] && key !== 'testMaterial')
808
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
809
+
810
+ // 3. Get Test Material
811
+ const testMaterial = materials.testMaterial ? { testMaterial: materials.testMaterial } : {};
812
+
813
+ // Combine in desired order
814
+ return {
815
+ ...originalMaterials,
816
+ ...customMaterials,
817
+ ...testMaterial
818
+ };
819
+ };
820
+
821
+ // New function to separate materials for mobile view
822
+ const getMobileSortedElements = (materials, styleMode, handleAddMaterial, handleGenerate) => {
823
+ // 1. Extract all materials except testMaterial (Surprise Me)
824
+ const regularMaterials = Object.entries(materials)
825
+ .filter(([key]) => key !== 'testMaterial')
826
+ .map(([key, material]) => ({ key, material }));
827
+
828
+ // 2. Get the Surprise Me button if it exists
829
+ const testMaterial = materials.testMaterial ? { key: 'testMaterial', material: materials.testMaterial } : null;
830
+
831
+ return {
832
+ addButton: handleAddMaterial,
833
+ materials: regularMaterials,
834
+ surpriseButton: testMaterial
835
+ };
836
+ };
837
+
838
+ // Add near your existing compression functions
839
+ const compressImageForAPI = async (dataUrl) => {
840
+ // Use a moderate size to ensure API can handle it
841
+ const maxWidth = 800;
842
+ const quality = 0.7;
843
+
844
+ return new Promise((resolve) => {
845
+ const img = new Image();
846
+ img.onload = () => {
847
+ const canvas = document.createElement('canvas');
848
+ const ctx = canvas.getContext('2d');
849
+
850
+ let width = img.width;
851
+ let height = img.height;
852
+
853
+ if (width > maxWidth) {
854
+ height = (height * maxWidth) / width;
855
+ width = maxWidth;
856
+ }
857
+
858
+ canvas.width = width;
859
+ canvas.height = height;
860
+
861
+ ctx.drawImage(img, 0, 0, width, height);
862
+ resolve(canvas.toDataURL('image/jpeg', quality));
863
+ };
864
+ img.src = dataUrl;
865
+ });
866
+ };
867
+
868
+ // Fix the reference image upload function
869
+ const handleReferenceImageUpload = async (e) => {
870
+ const file = e.target.files?.[0];
871
+ if (!file) return;
872
+
873
+ // Clear recentlyAdded to ensure we're not in edit mode
874
+ setRecentlyAdded(null);
875
+
876
+ // Set loading states
877
+ setIsGeneratingText(true);
878
+ setIsGeneratingPreview(true);
879
+
880
+ try {
881
+ // Process the image for preview
882
+ const reader = new FileReader();
883
+ reader.onloadend = async (event) => {
884
+ const imageDataUrl = event.target.result;
885
+
886
+ // Set UI state for image
887
+ setCustomImagePreview(imageDataUrl);
888
+ setUseCustomImage(true);
889
+
890
+ // Get custom API key if it exists
891
+ const customApiKey = localStorage.getItem('customApiKey');
892
+
893
+ // Call the visual-enhance-prompt API
894
+ try {
895
+ const compressedImage = await compressImageForAPI(imageDataUrl);
896
+
897
+ const response = await fetch('/api/visual-enhance-prompt', {
898
+ method: 'POST',
899
+ headers: {
900
+ 'Content-Type': 'application/json',
901
+ },
902
+ body: JSON.stringify({
903
+ image: compressedImage,
904
+ customApiKey,
905
+ basePrompt: 'Transform this sketch into a material with professional studio lighting against a pure black background. Render it in Cinema 4D with Octane for a high-end 3D visualization.'
906
+ }),
907
+ });
908
+
909
+ console.log('API response status:', response.status);
910
+
911
+ if (!response.ok) {
912
+ const errorText = await response.text().catch(() => '');
913
+ console.error('API error details:', errorText);
914
+ console.error(`API returned ${response.status}`);
915
+
916
+ // Set fallback values right here when the API response is not OK
917
+ setGeneratedMaterialName('Reference Material');
918
+ setNewMaterialName('Reference Material');
919
+ setGeneratedPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.');
920
+ setCustomPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.');
921
+ setShowCustomPrompt(true);
922
+ } else {
923
+ const data = await response.json();
924
+
925
+ if (data.enhancedPrompt && data.suggestedName) {
926
+ // Update the material information
927
+ setGeneratedMaterialName(data.suggestedName);
928
+ setNewMaterialName(data.suggestedName);
929
+ setGeneratedPrompt(data.enhancedPrompt);
930
+ setCustomPrompt(data.enhancedPrompt);
931
+ setShowCustomPrompt(true);
932
+
933
+ // If we have imageData from the API, use it as the preview
934
+ if (data.imageData) {
935
+ setPreviewThumbnail(`data:image/jpeg;base64,${data.imageData}`);
936
+ setUseCustomImage(false); // Use the generated thumbnail instead of the raw image
937
+ }
938
+ } else {
939
+ // Handle case where API returns success but missing data
940
+ console.error('API response missing enhancedPrompt or suggestedName fields');
941
+ setGeneratedMaterialName('Reference Material');
942
+ setNewMaterialName('Reference Material');
943
+ setGeneratedPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.');
944
+ setCustomPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.');
945
+ setShowCustomPrompt(true);
946
+ }
947
+ }
948
+ } catch (error) {
949
+ console.error('Error analyzing reference image:', error);
950
+
951
+ // Show specific error message for quota exceeded
952
+ if (error.message?.includes('429')) {
953
+ alert('API quota exceeded. Please try again later or add your own API key in settings.');
954
+ }
955
+
956
+ // Set fallback values
957
+ setGeneratedMaterialName('Reference Material');
958
+ setNewMaterialName('Reference Material');
959
+ setGeneratedPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.');
960
+ setCustomPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.');
961
+ setShowCustomPrompt(true);
962
+ } finally {
963
+ setIsGeneratingText(false);
964
+ setIsGeneratingPreview(false);
965
+ }
966
+ };
967
+
968
+ reader.readAsDataURL(file);
969
+ } catch (error) {
970
+ console.error('Error processing image:', error);
971
+ setIsGeneratingText(false);
972
+ setIsGeneratingPreview(false);
973
+ }
974
+ };
975
+
976
+ // Add this near the top of the component
977
+ useEffect(() => {
978
+ const handleClickOutside = (event) => {
979
+ // Close if clicking outside the popover and the style selector
980
+ if (showPromptInfo &&
981
+ !event.target.closest('.prompt-popover') &&
982
+ !event.target.closest('.material-edit-button')) {
983
+ setShowPromptInfo(null);
984
+ setEditingPrompt(null);
985
+ }
986
+ };
987
+
988
+ document.addEventListener('mousedown', handleClickOutside);
989
+ return () => document.removeEventListener('mousedown', handleClickOutside);
990
+ }, [showPromptInfo]);
991
+
992
+ // Update the calculatePopoverPosition function
993
+ const calculatePopoverPosition = (buttonElement, materialKey) => {
994
+ if (!buttonElement) return;
995
+
996
+ // If clicking the same material that's already open, close it
997
+ if (showPromptInfo === materialKey) {
998
+ setShowPromptInfo(null);
999
+ setEditingPrompt(null);
1000
+ setEditedPromptText('');
1001
+ setHasPromptChanged(false);
1002
+ return;
1003
+ }
1004
+
1005
+ const rect = buttonElement.getBoundingClientRect();
1006
+ const popoverWidth = 300;
1007
+
1008
+ setPromptPopoverPosition({
1009
+ top: rect.top - 10,
1010
+ left: rect.left + (rect.width / 2) - (popoverWidth / 2)
1011
+ });
1012
+
1013
+ // Set which material's prompt to show
1014
+ setShowPromptInfo(materialKey);
1015
+
1016
+ // Reset editing state
1017
+ setEditingPrompt(null);
1018
+ setEditedPromptText(materials[materialKey]?.prompt || '');
1019
+ setHasPromptChanged(false);
1020
+ };
1021
+
1022
+ // Add function to handle text changes in the textarea
1023
+ const handlePromptTextChange = (e) => {
1024
+ const newText = e.target.value;
1025
+ setEditedPromptText(newText);
1026
+
1027
+ // Check if the text has changed from the original
1028
+ if (editingPrompt && materials[editingPrompt]) {
1029
+ const originalPrompt = materials[editingPrompt].prompt || '';
1030
+ setHasPromptChanged(newText !== originalPrompt);
1031
+ }
1032
+ };
1033
+
1034
+ // Add function to save edited prompt
1035
+ const saveEditedPrompt = () => {
1036
+ if (!editingPrompt || !editedPromptText.trim() || !hasPromptChanged) return;
1037
+
1038
+ try {
1039
+ // Create updated material with new prompt
1040
+ const updatedMaterial = {
1041
+ ...materials[editingPrompt],
1042
+ prompt: editedPromptText.trim()
1043
+ };
1044
+
1045
+ // Update materials state and styleOptions
1046
+ const updatedMaterials = {
1047
+ ...materials,
1048
+ [editingPrompt]: updatedMaterial
1049
+ };
1050
+
1051
+ setMaterials(updatedMaterials);
1052
+ styleOptions = updatedMaterials;
1053
+
1054
+ // Save to localStorage (only custom materials)
1055
+ const customMaterials = {};
1056
+ for (const [k, v] of Object.entries(updatedMaterials)) {
1057
+ if (v.isCustom) {
1058
+ customMaterials[k] = v;
1059
+ }
1060
+ }
1061
+
1062
+ localStorage.setItem('customMaterials', JSON.stringify(customMaterials));
1063
+
1064
+ // Close edit mode
1065
+ setEditingPrompt(null);
1066
+ setShowPromptInfo(null);
1067
+ setHasPromptChanged(false);
1068
+ } catch (error) {
1069
+ console.error('Error saving edited prompt:', error);
1070
+ }
1071
+ };
1072
+
1073
+ // Add function to cancel editing
1074
+ const cancelEditing = () => {
1075
+ setEditingPrompt(null);
1076
+ setEditedPromptText('');
1077
+ setHasPromptChanged(false);
1078
+ };
1079
+
1080
+ const handleGenerateThumbnail = useCallback(async () => {
1081
+ // Prevent generation if text is still generating or already previewing
1082
+ if (isGeneratingText || isGeneratingPreview || !generatedPrompt) return;
1083
+
1084
+ console.log('Starting thumbnail generation...');
1085
+ setIsGeneratingPreview(true);
1086
+ setThumbnailError(null); // Clear previous errors
1087
+
1088
+ // --- CONFIRM THE PROMPT BEING USED ---
1089
+ console.log('Using prompt for thumbnail:', generatedPrompt.substring(0, 100) + '...');
1090
+ // --- You can check this log in your browser console ---
1091
+
1092
+ try {
1093
+ // Fetch the thumbnail from the API endpoint
1094
+ const response = await fetch("/api/generate-thumbnail", {
1095
+ method: "POST",
1096
+ headers: { "Content-Type": "application/json" },
1097
+ // Send the CURRENT generatedPrompt and the default shape
1098
+ body: JSON.stringify({
1099
+ prompt: generatedPrompt, // <-- Uses the state variable!
1100
+ referenceImageData: DEFAULT_SHAPE_DATA_URL // Base shape image
1101
+ }),
1102
+ });
1103
+
1104
+ if (!response.ok) {
1105
+ // Handle HTTP errors from the API
1106
+ const errorData = await response.json().catch(() => ({ error: 'Failed to parse error response' }));
1107
+ throw new Error(`API Error (${response.status}): ${errorData.error || 'Unknown error'}`);
1108
+ }
1109
+
1110
+ const data = await response.json();
1111
+
1112
+ if (data.success && data.imageData) {
1113
+ // Update state with the new thumbnail data
1114
+ setGeneratedThumbnail(`data:image/png;base64,${data.imageData}`);
1115
+ console.log('Thumbnail generated successfully.');
1116
+ } else {
1117
+ // Handle cases where API reports success=false or missing data
1118
+ throw new Error(data.error || 'Thumbnail generation failed: No image data received.');
1119
+ }
1120
+ } catch (error) {
1121
+ console.error("Error generating thumbnail:", error);
1122
+ setThumbnailError(error.message || 'An unexpected error occurred during thumbnail generation.');
1123
+ setGeneratedThumbnail(null); // Clear any previous thumbnail on error
1124
+ } finally {
1125
+ setIsGeneratingPreview(false); // Ensure loading state is turned off
1126
+ }
1127
+ }, [generatedPrompt, isGeneratingText, isGeneratingPreview]); // Dependencies
1128
+
1129
+ return (
1130
+ <div className="w-full" ref={styleSelectorRef}>
1131
+ {/* Inject scrollbar hiding CSS */}
1132
+ <style dangerouslySetInnerHTML={{ __html: scrollbarHideStyles }} />
1133
+
1134
+ {/* Mobile View */}
1135
+ <div className="md:hidden w-full">
1136
+ <div className="overflow-x-auto pb-2 scrollbar-hide">
1137
+ <div className="flex flex-nowrap gap-2 pr-2" style={{ minWidth: 'min-content' }}>
1138
+ {/* Add Material Button (First) */}
1139
+ <button
1140
+ onClick={handleAddMaterial}
1141
+ type="button"
1142
+ aria-label="Add new material"
1143
+ className="focus:outline-none group flex-shrink-0"
1144
+ style={{ scrollSnapAlign: 'start' }}
1145
+ >
1146
+ <div className="w-20 border border-dashed border-gray-200 overflow-hidden rounded-xl bg-gray-50 flex flex-col group-hover:bg-white group-hover:border-gray-300">
1147
+ <div className="w-full relative flex-1 flex items-center justify-center" style={{ aspectRatio: '1/1' }}>
1148
+ <Plus className="w-6 h-6 text-gray-500 group-hover:text-gray-600" />
1149
+ </div>
1150
+ <div className="px-1 py-1 text-left text-xs font-medium bg-gray-50 text-gray-500 w-full group-hover:bg-white group-hover:text-gray-600">
1151
+ <div className="truncate">
1152
+ Add Material
1153
+ </div>
1154
+ </div>
1155
+ </div>
1156
+ </button>
1157
+
1158
+ {/* Regular Materials (Middle) */}
1159
+ {Object.entries(materials)
1160
+ .filter(([key]) => key !== 'testMaterial')
1161
+ .map(([key, { name, file, imageData, thumbnail, isCustom, prompt }]) => (
1162
+ <button
1163
+ key={key}
1164
+ onClick={async () => {
1165
+ const isSameMaterial = styleMode === key;
1166
+ if (!isSameMaterial) {
1167
+ setStyleMode(key);
1168
+ } else {
1169
+ handleGenerate();
1170
+ }
1171
+ }}
1172
+ type="button"
1173
+ aria-label={`Select ${name} style`}
1174
+ aria-pressed={styleMode === key}
1175
+ className="focus:outline-none relative group flex-shrink-0"
1176
+ style={{ scrollSnapAlign: 'start' }}
1177
+ >
1178
+ <div className={`w-20 border overflow-hidden rounded-xl ${
1179
+ styleMode === key
1180
+ ? 'border-blue-500 bg-white'
1181
+ : 'bg-gray-50 border-gray-200 group-hover:bg-white group-hover:border-gray-300'
1182
+ }`}>
1183
+ <div className="w-full relative" style={{ aspectRatio: '1/1' }}>
1184
+ <img
1185
+ src={imageData ? `data:image/jpeg;base64,${imageData}` : (file ? `/samples/${file}` : thumbnail || '')}
1186
+ alt={`${name} style example`}
1187
+ className="w-full h-full object-cover"
1188
+ onError={(e) => {
1189
+ console.error(`Error loading thumbnail for ${name} from ${e.target.src}`);
1190
+ // Provide a base64 encoded 1x1 transparent PNG as fallback
1191
+ e.target.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
1192
+ }}
1193
+ />
1194
+ <div className="absolute inset-0">
1195
+ <div className="opacity-0 group-hover:opacity-100 transition-opacity">
1196
+ <button
1197
+ type="button"
1198
+ onClick={(e) => {
1199
+ e.preventDefault();
1200
+ e.stopPropagation();
1201
+ calculatePopoverPosition(e.currentTarget, key);
1202
+ }}
1203
+ className="material-edit-button absolute top-1 right-1 bg-gray-800 bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-5 h-5 flex items-center justify-center transition-opacity"
1204
+ aria-label={isCustom ? `Edit ${name} material` : `View ${name} prompt`}
1205
+ >
1206
+ <Info className="w-2.5 h-2.5" />
1207
+ </button>
1208
+
1209
+ {isCustom && (
1210
+ <button
1211
+ type="button"
1212
+ onClick={(e) => {
1213
+ e.preventDefault();
1214
+ e.stopPropagation();
1215
+ handleDeleteMaterial(e, key);
1216
+ }}
1217
+ className="absolute top-1 left-1 bg-gray-800 bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-5 h-5 flex items-center justify-center transition-opacity"
1218
+ aria-label={`Delete ${name} material`}
1219
+ >
1220
+ <Trash2 className="w-2.5 h-2.5" />
1221
+ </button>
1222
+ )}
1223
+ </div>
1224
+ </div>
1225
+ </div>
1226
+ <div className={`px-1 py-1 text-left text-xs font-medium ${
1227
+ styleMode === key
1228
+ ? 'bg-blue-50 text-blue-600'
1229
+ : 'bg-gray-50 text-gray-500 group-hover:bg-white group-hover:text-gray-600'
1230
+ }`}>
1231
+ <div className="truncate">
1232
+ {name}
1233
+ </div>
1234
+ </div>
1235
+ </div>
1236
+ </button>
1237
+ ))}
1238
+
1239
+ {/* Surprise Me Button (Last) */}
1240
+ {materials.testMaterial && (
1241
+ <button
1242
+ key="testMaterial"
1243
+ onClick={async () => {
1244
+ const isSameMaterial = styleMode === 'testMaterial';
1245
+ if (!isSameMaterial) {
1246
+ setStyleMode('testMaterial');
1247
+ } else {
1248
+ handleGenerate();
1249
+ }
1250
+ }}
1251
+ type="button"
1252
+ aria-label={`Select ${materials.testMaterial.name} style`}
1253
+ aria-pressed={styleMode === 'testMaterial'}
1254
+ className="focus:outline-none relative group flex-shrink-0"
1255
+ style={{ scrollSnapAlign: 'end' }}
1256
+ >
1257
+ <div className={`w-20 border overflow-hidden rounded-xl ${
1258
+ styleMode === 'testMaterial'
1259
+ ? 'border-blue-500 bg-white'
1260
+ : 'bg-gray-50 border-gray-200 border-dashed group-hover:bg-white group-hover:border-gray-300'
1261
+ }`}>
1262
+ <div className="w-full relative" style={{ aspectRatio: '1/1' }}>
1263
+ <div className={`w-full h-full flex items-center justify-center ${
1264
+ styleMode === 'testMaterial' ? 'bg-white' : 'bg-gray-50 group-hover:bg-white'
1265
+ }`}>
1266
+ <HelpCircle className={`w-8 h-8 ${
1267
+ styleMode === 'testMaterial' ? 'text-blue-600' : 'text-gray-500 group-hover:text-gray-600'
1268
+ }`} />
1269
+ </div>
1270
+ </div>
1271
+ <div className={`px-1 py-1 text-left text-xs font-medium ${
1272
+ styleMode === 'testMaterial'
1273
+ ? 'bg-blue-50 text-blue-600'
1274
+ : 'bg-gray-50 text-gray-500 group-hover:bg-white group-hover:text-gray-600'
1275
+ }`}>
1276
+ <div className="truncate">
1277
+ {materials.testMaterial.name}
1278
+ </div>
1279
+ </div>
1280
+ </div>
1281
+ </button>
1282
+ )}
1283
+ </div>
1284
+ </div>
1285
+ </div>
1286
+
1287
+ {/* Desktop View */}
1288
+ <div className="hidden md:block">
1289
+ <div className="overflow-x-auto">
1290
+ <div className="flex flex-wrap gap-2 overflow-y-auto max-h-[35vh] pr-2">
1291
+ {/* 1. Original + 2. Local + 3. Test Material */}
1292
+ {Object.entries(getSortedMaterials(materials)).map(([key, { name, file, imageData, thumbnail, isCustom, prompt }]) => (
1293
+ <button
1294
+ key={key}
1295
+ onClick={async () => {
1296
+ const isSameMaterial = styleMode === key;
1297
+ if (!isSameMaterial) {
1298
+ setStyleMode(key);
1299
+ } else {
1300
+ handleGenerate();
1301
+ }
1302
+ }}
1303
+ type="button"
1304
+ aria-label={`Select ${name} style`}
1305
+ aria-pressed={styleMode === key}
1306
+ className="focus:outline-none relative group"
1307
+ >
1308
+ <div className={`w-20 border overflow-hidden rounded-xl ${
1309
+ styleMode === key
1310
+ ? key === 'testMaterial' ? 'border-blue-500 bg-white' : 'border-blue-500 bg-white'
1311
+ : key === 'testMaterial'
1312
+ ? 'bg-gray-50 border-gray-200 border-dashed group-hover:bg-white group-hover:border-gray-300'
1313
+ : 'bg-gray-50 border-gray-200 group-hover:bg-white group-hover:border-gray-300'
1314
+ }`}>
1315
+ <div className="w-full relative" style={{ aspectRatio: '1/1' }}>
1316
+ {key === 'testMaterial' ? (
1317
+ <div className={`w-full h-full flex items-center justify-center ${
1318
+ styleMode === key ? 'bg-white' : 'bg-gray-50 group-hover:bg-white'
1319
+ }`}>
1320
+ <HelpCircle className={`w-8 h-8 ${
1321
+ styleMode === key ? 'text-blue-600' : 'text-gray-500 group-hover:text-gray-600'
1322
+ }`} />
1323
+ </div>
1324
+ ) : (
1325
+ <img
1326
+ src={imageData ? `data:image/jpeg;base64,${imageData}` : (file ? `/samples/${file}` : thumbnail || '')}
1327
+ alt={`${name} style example`}
1328
+ className="w-full h-full object-cover"
1329
+ onError={(e) => {
1330
+ console.error(`Error loading thumbnail for ${name} from ${e.target.src}`);
1331
+ // Provide a base64 encoded 1x1 transparent PNG as fallback
1332
+ e.target.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
1333
+ }}
1334
+ />
1335
+ )}
1336
+ <div className="absolute inset-0">
1337
+ <div className="opacity-0 group-hover:opacity-100 transition-opacity">
1338
+ <button
1339
+ type="button"
1340
+ onClick={(e) => {
1341
+ e.preventDefault();
1342
+ e.stopPropagation();
1343
+ calculatePopoverPosition(e.currentTarget, key);
1344
+ }}
1345
+ className="material-edit-button absolute top-1 right-1 bg-gray-800 bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-5 h-5 flex items-center justify-center transition-opacity"
1346
+ aria-label={isCustom ? `Edit ${name} material` : `View ${name} prompt`}
1347
+ >
1348
+ <Info className="w-2.5 h-2.5" />
1349
+ </button>
1350
+
1351
+ {isCustom && (
1352
+ <button
1353
+ type="button"
1354
+ onClick={(e) => {
1355
+ e.preventDefault();
1356
+ e.stopPropagation();
1357
+ handleDeleteMaterial(e, key);
1358
+ }}
1359
+ className="absolute top-1 left-1 bg-gray-800 bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-5 h-5 flex items-center justify-center transition-opacity"
1360
+ aria-label={`Delete ${name} material`}
1361
+ >
1362
+ <Trash2 className="w-2.5 h-2.5" />
1363
+ </button>
1364
+ )}
1365
+ </div>
1366
+ </div>
1367
+ </div>
1368
+ <div className={`px-1 py-1 text-left text-xs font-medium ${
1369
+ styleMode === key
1370
+ ? 'bg-blue-50 text-blue-600'
1371
+ : 'bg-gray-50 text-gray-500 group-hover:bg-white group-hover:text-gray-600'
1372
+ }`}>
1373
+ <div className="truncate">
1374
+ {name}
1375
+ </div>
1376
+ </div>
1377
+ </div>
1378
+ </button>
1379
+ ))}
1380
+
1381
+ <button
1382
+ onClick={handleAddMaterial}
1383
+ type="button"
1384
+ aria-label="Add new material"
1385
+ className="focus:outline-none group"
1386
+ >
1387
+ <div className="w-20 border border-dashed border-gray-200 overflow-hidden rounded-xl bg-gray-50 flex flex-col group-hover:bg-white group-hover:border-gray-300">
1388
+ <div className="w-full relative flex-1 flex items-center justify-center" style={{ aspectRatio: '1/1' }}>
1389
+ <Plus className="w-6 h-6 text-gray-500 group-hover:text-gray-600" />
1390
+ </div>
1391
+ <div className="px-1 py-1 text-left text-xs font-medium bg-gray-50 text-gray-500 w-full group-hover:bg-white group-hover:text-gray-600">
1392
+ <div className="truncate">
1393
+ Add Material
1394
+ </div>
1395
+ </div>
1396
+ </div>
1397
+ </button>
1398
+ </div>
1399
+ </div>
1400
+ </div>
1401
+
1402
+ {showPromptInfo && typeof document !== 'undefined' && createPortal(
1403
+ <div
1404
+ className="prompt-popover fixed z-[9999] bg-white border border-gray-200 text-gray-900 rounded-lg shadow-lg p-4 text-xs"
1405
+ style={{
1406
+ top: `${promptPopoverPosition.top}px`,
1407
+ left: `${promptPopoverPosition.left}px`,
1408
+ width: '300px',
1409
+ transform: 'translateY(-100%)',
1410
+ maxHeight: '60vh',
1411
+ }}
1412
+ >
1413
+ <button
1414
+ type="button"
1415
+ className="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
1416
+ onClick={() => {
1417
+ setShowPromptInfo(null);
1418
+ setEditingPrompt(null);
1419
+ }}
1420
+ aria-label="Close prompt info"
1421
+ >
1422
+ ×
1423
+ </button>
1424
+
1425
+ <div className="text-sm font-medium mb-2 text-gray-700">
1426
+ {materials[showPromptInfo]?.name || ''}
1427
+ </div>
1428
+
1429
+ {materials[showPromptInfo]?.isCustom ? (
1430
+ <>
1431
+ <textarea
1432
+ className={`font-mono text-xs w-full h-60 bg-gray-50 border ${editingPrompt ? 'border-blue-300 focus:border-blue-500' : 'border-gray-200'} rounded p-2 text-gray-900`}
1433
+ value={editingPrompt ? editedPromptText : (materials[showPromptInfo]?.prompt || '')}
1434
+ onChange={handlePromptTextChange}
1435
+ onClick={(e) => {
1436
+ e.stopPropagation();
1437
+ if (!editingPrompt) {
1438
+ setEditingPrompt(showPromptInfo);
1439
+ setEditedPromptText(materials[showPromptInfo]?.prompt || '');
1440
+ }
1441
+ }}
1442
+ onFocus={(e) => {
1443
+ if (!editingPrompt) {
1444
+ setEditingPrompt(showPromptInfo);
1445
+ setEditedPromptText(materials[showPromptInfo]?.prompt || '');
1446
+ // Place cursor at end of text
1447
+ const val = e.target.value;
1448
+ e.target.value = '';
1449
+ e.target.value = val;
1450
+ }
1451
+ }}
1452
+ onKeyDown={(e) => {
1453
+ // Save changes on Ctrl+Enter or Cmd+Enter
1454
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
1455
+ e.preventDefault();
1456
+ if (editingPrompt && hasPromptChanged) {
1457
+ saveEditedPrompt();
1458
+ }
1459
+ }
1460
+ e.stopPropagation();
1461
+ }}
1462
+ placeholder="Edit prompt here..."
1463
+ readOnly={!editingPrompt}
1464
+ />
1465
+ {/* {editingPrompt && (
1466
+ <div className="text-xs text-gray-500 mt-1 mb-2 flex items-center">
1467
+ <span className="mr-1">Pro tip:</span>
1468
+ <kbd className="px-1 py-0.5 border border-gray-300 rounded text-xs bg-gray-50 mr-1">
1469
+ {navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}
1470
+ </kbd>
1471
+ <span className="mr-1">+</span>
1472
+ <kbd className="px-1 py-0.5 border border-gray-300 rounded text-xs bg-gray-50">
1473
+ Enter
1474
+ </kbd>
1475
+ <span className="ml-1">to save</span>
1476
+ </div>
1477
+ )} */}
1478
+ <div className="flex justify-between mt-2">
1479
+ <button
1480
+ type="button"
1481
+ className="flex items-center bg-red-500 hover:bg-red-600 text-white rounded px-2 py-1 text-xs"
1482
+ onClick={(e) => {
1483
+ e.preventDefault();
1484
+ e.stopPropagation();
1485
+ handleDeleteMaterial(e, showPromptInfo);
1486
+ setShowPromptInfo(null);
1487
+ }}
1488
+ >
1489
+ <Trash2 className="w-3 h-3 mr-1" />
1490
+ Delete
1491
+ </button>
1492
+ <button
1493
+ type="button"
1494
+ className={`flex items-center ${
1495
+ editingPrompt
1496
+ ? hasPromptChanged
1497
+ ? 'bg-blue-500 hover:bg-blue-600'
1498
+ : 'bg-gray-300 text-gray-500 cursor-not-allowed'
1499
+ : 'bg-blue-500 hover:bg-blue-600'
1500
+ } text-white rounded px-2 py-1 text-xs`}
1501
+ onClick={(e) => {
1502
+ e.preventDefault();
1503
+ e.stopPropagation();
1504
+ if (editingPrompt) {
1505
+ if (hasPromptChanged) {
1506
+ saveEditedPrompt();
1507
+ }
1508
+ } else {
1509
+ setEditingPrompt(showPromptInfo);
1510
+ setEditedPromptText(materials[showPromptInfo]?.prompt || '');
1511
+ }
1512
+ }}
1513
+ disabled={editingPrompt && !hasPromptChanged}
1514
+ >
1515
+ {editingPrompt
1516
+ ? <><Check className="w-3 h-3 mr-1" />Save Changes</>
1517
+ : <><Edit className="w-3 h-3 mr-1" />Edit Prompt</>
1518
+ }
1519
+ </button>
1520
+ </div>
1521
+ </>
1522
+ ) : (
1523
+ <>
1524
+ <div className="font-mono text-xs overflow-y-auto max-h-60 border border-gray-200 rounded p-2 bg-gray-50 mb-3">
1525
+ {materials[showPromptInfo]?.prompt || 'No prompt available'}
1526
+ </div>
1527
+ </>
1528
+ )}
1529
+
1530
+ <div className="absolute bottom-0 left-1/2 w-3 h-3 bg-white border-b border-r border-gray-200 transform translate-y-1/2 rotate-45 -translate-x-1/2" />
1531
+ </div>,
1532
+ document.body
1533
+ )}
1534
+
1535
+ <AddMaterialModal
1536
+ showModal={showAddMaterialModal}
1537
+ onClose={() => setShowAddMaterialModal(false)}
1538
+ onAddMaterial={handleCreateMaterial}
1539
+ newMaterialName={newMaterialName}
1540
+ setNewMaterialName={setNewMaterialName}
1541
+ generatedMaterialName={generatedMaterialName}
1542
+ setGeneratedMaterialName={setGeneratedMaterialName}
1543
+ generatedPrompt={generatedPrompt}
1544
+ setGeneratedPrompt={setGeneratedPrompt}
1545
+ customPrompt={customPrompt}
1546
+ setCustomPrompt={setCustomPrompt}
1547
+ previewThumbnail={previewThumbnail}
1548
+ customImagePreview={customImagePreview}
1549
+ useCustomImage={useCustomImage}
1550
+ isGeneratingPreview={isGeneratingPreview}
1551
+ isGeneratingText={isGeneratingText}
1552
+ showMaterialNameEdit={showMaterialNameEdit}
1553
+ setShowMaterialNameEdit={setShowMaterialNameEdit}
1554
+ showCustomPrompt={showCustomPrompt}
1555
+ setShowCustomPrompt={setShowCustomPrompt}
1556
+ handleRefreshThumbnail={handleRefreshThumbnail}
1557
+ handleReferenceImageUpload={handleReferenceImageUpload}
1558
+ handleNewMaterialDescription={handleNewMaterialDescription}
1559
+ onStyleSelected={(materialKey) => {
1560
+ // Update the styleMode to select the new material
1561
+ setStyleMode(materialKey);
1562
+ // Update the materials state to reflect the change
1563
+ setMaterials({...styleOptions});
1564
+ }}
1565
+ materials={materials}
1566
+ />
1567
+ </div>
1568
+ );
1569
+ };
1570
+
1571
+ export default StyleSelector;
components/TextInput.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ const TextInput = ({
4
+ isTyping,
5
+ textInputRef,
6
+ textInput,
7
+ setTextInput,
8
+ handleTextInput,
9
+ textPosition
10
+ }) => {
11
+ if (!isTyping) {
12
+ return null;
13
+ }
14
+
15
+ return (
16
+ <div
17
+ className="absolute z-50 bg-white border border-gray-300 rounded-lg shadow-medium p-2"
18
+ style={{
19
+ top: textPosition.y,
20
+ left: textPosition.x,
21
+ transform: 'translateY(-100%)'
22
+ }}
23
+ >
24
+ <input
25
+ ref={textInputRef}
26
+ type="text"
27
+ value={textInput}
28
+ onChange={(e) => setTextInput(e.target.value)}
29
+ onKeyDown={handleTextInput}
30
+ placeholder="Type text..."
31
+ className="w-full p-2 bg-gray-50 border border-gray-200 rounded-lg text-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-400"
32
+ aria-label="Text input for canvas"
33
+ />
34
+ </div>
35
+ );
36
+ };
37
+
38
+ export default TextInput;
components/ToolBar.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Pencil, Eraser, Type, Undo, Trash2, PenLine, MousePointer } from 'lucide-react';
2
+ import { PenTool } from 'lucide-react';
3
+ import { Undo2 } from 'lucide-react';
4
+ import { useState } from 'react';
5
+ import DimensionSelector from './DimensionSelector';
6
+
7
+ const ToolBar = ({
8
+ currentTool,
9
+ setCurrentTool,
10
+ handleUndo,
11
+ clearCanvas,
12
+ orientation = 'horizontal',
13
+ currentWidth,
14
+ setStrokeWidth,
15
+ currentDimension,
16
+ onDimensionChange
17
+ }) => {
18
+ const mainTools = [
19
+ // { id: 'selection', icon: MousePointer, label: 'Selection' },
20
+ { id: 'pencil', icon: Pencil, label: 'Pencil' },
21
+ // { id: 'pen', icon: PenTool, label: 'Pen' },
22
+ { id: 'eraser', icon: Eraser, label: 'Eraser' },
23
+ // { id: 'text', icon: Type, label: 'Text' }
24
+ ];
25
+
26
+ const actions = [
27
+ { id: 'undo', icon: Undo2, label: 'Undo', onClick: handleUndo },
28
+ { id: 'clear', icon: Trash2, label: 'Clear', onClick: clearCanvas }
29
+ ];
30
+
31
+ const containerClasses = orientation === 'vertical'
32
+ ? 'flex flex-col gap-2 bg-white rounded-xl shadow-soft p-2 border border-gray-200'
33
+ : 'flex gap-2 bg-white rounded-xl shadow-soft p-2 border border-gray-200';
34
+
35
+ return (
36
+ <div className={orientation === 'vertical' ? 'flex flex-col gap-2' : 'flex flex-col gap-3 mt-1'}>
37
+ {/* Main toolbar container - includes dimension selector in horizontal mode */}
38
+ <div className={orientation === 'horizontal' ? 'flex items-center justify-between' : ''}>
39
+ {/* Main toolbar */}
40
+ <div className={containerClasses}>
41
+ {mainTools.map((tool) => (
42
+ <div key={tool.id} className="relative">
43
+ <button
44
+ onClick={() => setCurrentTool(tool.id)}
45
+ className={`p-2.5 rounded-lg transition-colors flex items-center justify-center ${
46
+ currentTool === tool.id
47
+ ? 'bg-gray-100 text-gray-900'
48
+ : 'text-gray-600 hover:bg-gray-50'
49
+ }`}
50
+ title={tool.label}
51
+ >
52
+ <tool.icon className="w-5 h-5" />
53
+ </button>
54
+ </div>
55
+ ))}
56
+
57
+ {orientation === 'vertical' && <div className="h-px bg-gray-200 my-2" />}
58
+ {orientation === 'horizontal' && <div className="w-px bg-gray-200 mx-0" />}
59
+
60
+ {actions.map((action) => (
61
+ <button
62
+ key={action.id}
63
+ onClick={action.onClick}
64
+ className="p-2 rounded-lg text-gray-600 hover:bg-gray-50 text-center flex items-center justify-center transition-colors"
65
+ title={action.label}
66
+ >
67
+ <action.icon className="w-5 h-5" />
68
+ </button>
69
+ ))}
70
+ </div>
71
+
72
+ {/* Dimension selector - show inline in horizontal layout */}
73
+ {orientation === 'horizontal' && currentDimension && onDimensionChange && (
74
+ <div className="ml-2 bg-white rounded-xl shadow-soft p-2 border border-gray-200">
75
+ <DimensionSelector
76
+ currentDimension={currentDimension}
77
+ onDimensionChange={onDimensionChange}
78
+ />
79
+ </div>
80
+ )}
81
+ </div>
82
+
83
+ {/* Separate DimensionSelector - only in vertical orientation */}
84
+ {orientation === 'vertical' && currentDimension && onDimensionChange && (
85
+ <div className="bg-white rounded-xl shadow-soft border border-gray-200">
86
+ <DimensionSelector
87
+ currentDimension={currentDimension}
88
+ onDimensionChange={onDimensionChange}
89
+ />
90
+ </div>
91
+ )}
92
+ </div>
93
+ );
94
+ };
95
+
96
+ export default ToolBar;
components/utils/canvasUtils.js ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Get the correct coordinates based on canvas scaling
2
+ export const getCoordinates = (e, canvas) => {
3
+ const rect = canvas.getBoundingClientRect();
4
+
5
+ // Calculate the scaling factor between the internal canvas size and displayed size
6
+ const scaleX = canvas.width / rect.width;
7
+ const scaleY = canvas.height / rect.height;
8
+
9
+ // Apply the scaling to get accurate coordinates
10
+ return {
11
+ x: (e.nativeEvent.offsetX || (e.nativeEvent.touches?.[0]?.clientX - rect.left)) * scaleX,
12
+ y: (e.nativeEvent.offsetY || (e.nativeEvent.touches?.[0]?.clientY - rect.top)) * scaleY
13
+ };
14
+ };
15
+
16
+ // Initialize canvas with white background
17
+ export const initializeCanvas = (canvas) => {
18
+ const ctx = canvas.getContext("2d");
19
+
20
+ // Fill canvas with white background
21
+ ctx.fillStyle = "#FFFFFF";
22
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
23
+ };
24
+
25
+ // Draw the background image to the canvas
26
+ export const drawImageToCanvas = (canvas, backgroundImage) => {
27
+ if (!canvas || !backgroundImage) return;
28
+
29
+ const ctx = canvas.getContext("2d");
30
+
31
+ // Fill with white background first
32
+ ctx.fillStyle = "#FFFFFF";
33
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
34
+
35
+ // Draw the background image
36
+ ctx.drawImage(
37
+ backgroundImage,
38
+ 0, 0,
39
+ canvas.width, canvas.height
40
+ );
41
+ };
42
+
43
+ // Draw bezier curve
44
+ export const drawBezierCurve = (canvas, points) => {
45
+ const ctx = canvas.getContext('2d');
46
+
47
+ if (!points || points.length < 2) {
48
+ console.error('Need at least 2 points to draw a path');
49
+ return;
50
+ }
51
+
52
+ ctx.beginPath();
53
+ ctx.strokeStyle = '#000000';
54
+ ctx.lineWidth = 4;
55
+
56
+ // Start at the first anchor point
57
+ ctx.moveTo(points[0].x, points[0].y);
58
+
59
+ // For each pair of anchor points (and their control points)
60
+ for (let i = 0; i < points.length - 1; i++) {
61
+ const current = points[i];
62
+ const next = points[i + 1];
63
+
64
+ if (current.handleOut && next.handleIn) {
65
+ // If both points have handles, draw a cubic bezier
66
+ ctx.bezierCurveTo(
67
+ current.x + (current.handleOut?.x || 0), current.y + (current.handleOut?.y || 0),
68
+ next.x + (next.handleIn?.x || 0), next.y + (next.handleIn?.y || 0),
69
+ next.x, next.y
70
+ );
71
+ } else {
72
+ // If no handles, draw a straight line
73
+ ctx.lineTo(next.x, next.y);
74
+ }
75
+ }
76
+
77
+ ctx.stroke();
78
+ };
79
+
80
+ // Draw bezier guides (control points and lines)
81
+ export const drawBezierGuides = (ctx, points) => {
82
+ if (!points || points.length === 0) return;
83
+
84
+ // Draw the path itself first (as a light preview)
85
+ ctx.save();
86
+ ctx.globalAlpha = 0.3;
87
+ ctx.strokeStyle = '#888888';
88
+ ctx.lineWidth = 1.5;
89
+
90
+ ctx.beginPath();
91
+ ctx.moveTo(points[0].x, points[0].y);
92
+
93
+ // For each pair of anchor points (and their control points)
94
+ for (let i = 0; i < points.length - 1; i++) {
95
+ const current = points[i];
96
+ const next = points[i + 1];
97
+
98
+ if (current.handleOut && next.handleIn) {
99
+ // If both points have handles, draw a cubic bezier
100
+ ctx.bezierCurveTo(
101
+ current.x + (current.handleOut?.x || 0), current.y + (current.handleOut?.y || 0),
102
+ next.x + (next.handleIn?.x || 0), next.y + (next.handleIn?.y || 0),
103
+ next.x, next.y
104
+ );
105
+ } else {
106
+ // If no handles, draw a straight line
107
+ ctx.lineTo(next.x, next.y);
108
+ }
109
+ }
110
+
111
+ ctx.stroke();
112
+ ctx.restore();
113
+
114
+ // Draw guide lines between anchor points and their handles
115
+ ctx.strokeStyle = 'rgba(100, 100, 255, 0.5)';
116
+ ctx.lineWidth = 1;
117
+
118
+ for (const point of points) {
119
+ // Draw line from anchor to in-handle if it exists
120
+ if (point.handleIn) {
121
+ ctx.beginPath();
122
+ ctx.moveTo(point.x, point.y);
123
+ ctx.lineTo(point.x + point.handleIn.x, point.y + point.handleIn.y);
124
+ ctx.stroke();
125
+ }
126
+
127
+ // Draw line from anchor to out-handle if it exists
128
+ if (point.handleOut) {
129
+ ctx.beginPath();
130
+ ctx.moveTo(point.x, point.y);
131
+ ctx.lineTo(point.x + point.handleOut.x, point.y + point.handleOut.y);
132
+ ctx.stroke();
133
+ }
134
+ }
135
+
136
+ // Draw anchor points (main points of the path)
137
+ for (const point of points) {
138
+ // Draw the main anchor point
139
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
140
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.8)';
141
+ ctx.lineWidth = 1;
142
+
143
+ ctx.beginPath();
144
+ ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
145
+ ctx.fill();
146
+ ctx.stroke();
147
+
148
+ // Draw the handle points if they exist
149
+ if (point.handleIn) {
150
+ ctx.fillStyle = 'rgba(100, 100, 255, 0.8)';
151
+ ctx.beginPath();
152
+ ctx.arc(point.x + point.handleIn.x, point.y + point.handleIn.y, 4, 0, Math.PI * 2);
153
+ ctx.fill();
154
+ }
155
+
156
+ if (point.handleOut) {
157
+ ctx.fillStyle = 'rgba(100, 100, 255, 0.8)';
158
+ ctx.beginPath();
159
+ ctx.arc(point.x + point.handleOut.x, point.y + point.handleOut.y, 4, 0, Math.PI * 2);
160
+ ctx.fill();
161
+ }
162
+ }
163
+ };
164
+
165
+ // Helper to create a new anchor point with handles
166
+ export const createAnchorPoint = (x, y, prevPoint = null) => {
167
+ // By default, create a point with no handles
168
+ const point = { x, y, handleIn: null, handleOut: null };
169
+
170
+ // If there's a previous point, automatically add symmetric handles
171
+ if (prevPoint) {
172
+ // Calculate the default handle length (as a percentage of distance to previous point)
173
+ const dx = x - prevPoint.x;
174
+ const dy = y - prevPoint.y;
175
+ const distance = Math.sqrt(dx * dx + dy * dy);
176
+ const handleLength = distance * 0.3; // 30% of distance between points
177
+
178
+ // Create handles perpendicular to the line between points
179
+ // For a smooth curve, make the previous point's out handle opposite to this point's in handle
180
+ const angle = Math.atan2(dy, dx);
181
+
182
+ // Add an out handle to the previous point (if it doesn't already have one)
183
+ if (!prevPoint.handleOut) {
184
+ prevPoint.handleOut = {
185
+ x: Math.cos(angle) * -handleLength,
186
+ y: Math.sin(angle) * -handleLength
187
+ };
188
+ }
189
+
190
+ // Add an in handle to the current point
191
+ point.handleIn = {
192
+ x: Math.cos(angle) * -handleLength,
193
+ y: Math.sin(angle) * -handleLength
194
+ };
195
+ }
196
+
197
+ return point;
198
+ };
199
+
200
+ // Helper to check if a point is near a handle
201
+ export const isNearHandle = (point, handleType, x, y, radius = 10) => {
202
+ if (!point || !point[handleType]) return false;
203
+
204
+ const handleX = point.x + point[handleType].x;
205
+ const handleY = point.y + point[handleType].y;
206
+
207
+ const dx = handleX - x;
208
+ const dy = handleY - y;
209
+
210
+ return (dx * dx + dy * dy) <= radius * radius;
211
+ };
212
+
213
+ // Helper to update a handle position
214
+ export const updateHandle = (point, handleType, dx, dy, symmetric = true) => {
215
+ if (!point || !point[handleType]) return;
216
+
217
+ // Update the target handle
218
+ point[handleType].x += dx;
219
+ point[handleType].y += dy;
220
+
221
+ // If symmetric and the other handle exists, update it to be symmetrical
222
+ if (symmetric) {
223
+ const otherType = handleType === 'handleIn' ? 'handleOut' : 'handleIn';
224
+
225
+ if (point[otherType]) {
226
+ point[otherType].x = -point[handleType].x;
227
+ point[otherType].y = -point[handleType].y;
228
+ }
229
+ }
230
+ };
docker-compose.yml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3'
2
+
3
+ services:
4
+ nextjs-app:
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ ports:
9
+ - "3000:3000"
10
+ environment:
11
+ - NODE_ENV=production
12
+ - GEMINI_API_KEY=${GEMINI_API_KEY}
13
+ restart: unless-stopped
14
+ healthcheck:
15
+ test: ["CMD", "wget", "--spider", "http://localhost:3000"]
16
+ interval: 30s
17
+ timeout: 10s
18
+ retries: 3
jsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "paths": {
4
+ "@/*": ["./*"]
5
+ }
6
+ }
7
+ }
next.config.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ reactStrictMode: true,
4
+ output: 'standalone',
5
+ images: {
6
+ domains: ['localhost'],
7
+ },
8
+ // Ensure that Webpack handles the canvas properly
9
+ webpack: (config) => {
10
+ // Add any necessary webpack configurations here
11
+ return config;
12
+ },
13
+ // Development configuration
14
+ experimental: {
15
+ // Disable experimental features that might cause issues
16
+ turbo: false,
17
+ // Enable more stable features
18
+ esmExternals: true,
19
+ // Improve module resolution
20
+ modularizeImports: {
21
+ '@material-ui/core/': {
22
+ transform: '@material-ui/core/{{member}}'
23
+ },
24
+ '@material-ui/icons/': {
25
+ transform: '@material-ui/icons/{{member}}'
26
+ }
27
+ }
28
+ },
29
+ api: {
30
+ bodyParser: {
31
+ sizeLimit: '10mb' // Increase the size limit to 10MB
32
+ }
33
+ }
34
+ };
35
+
36
+ module.exports = nextConfig;
package-lock.json ADDED
@@ -0,0 +1,1626 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "native-image",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "native-image",
9
+ "version": "0.1.0",
10
+ "dependencies": {
11
+ "@google/generative-ai": "^0.24.0",
12
+ "@types/react-dom": "^19.0.4",
13
+ "lucide-react": "^0.483.0",
14
+ "next": "15.2.3",
15
+ "react": "^19.0.0",
16
+ "react-dom": "^19.0.0",
17
+ "react-hot-toast": "^2.5.2",
18
+ "react-masonry-css": "^1.0.16",
19
+ "use-debounce": "^10.0.4"
20
+ },
21
+ "devDependencies": {
22
+ "@tailwindcss/postcss": "^4",
23
+ "@types/node": "22.13.14",
24
+ "@types/react": "19.0.12",
25
+ "tailwind-scrollbar": "^4.0.1",
26
+ "tailwindcss": "^4",
27
+ "typescript": "5.8.2"
28
+ }
29
+ },
30
+ "node_modules/@alloc/quick-lru": {
31
+ "version": "5.2.0",
32
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
33
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
34
+ "dev": true,
35
+ "license": "MIT",
36
+ "engines": {
37
+ "node": ">=10"
38
+ },
39
+ "funding": {
40
+ "url": "https://github.com/sponsors/sindresorhus"
41
+ }
42
+ },
43
+ "node_modules/@emnapi/runtime": {
44
+ "version": "1.3.1",
45
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
46
+ "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==",
47
+ "license": "MIT",
48
+ "optional": true,
49
+ "dependencies": {
50
+ "tslib": "^2.4.0"
51
+ }
52
+ },
53
+ "node_modules/@google/generative-ai": {
54
+ "version": "0.24.0",
55
+ "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.0.tgz",
56
+ "integrity": "sha512-fnEITCGEB7NdX0BhoYZ/cq/7WPZ1QS5IzJJfC3Tg/OwkvBetMiVJciyaan297OvE4B9Jg1xvo0zIazX/9sGu1Q==",
57
+ "license": "Apache-2.0",
58
+ "engines": {
59
+ "node": ">=18.0.0"
60
+ }
61
+ },
62
+ "node_modules/@img/sharp-darwin-arm64": {
63
+ "version": "0.33.5",
64
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
65
+ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
66
+ "cpu": [
67
+ "arm64"
68
+ ],
69
+ "license": "Apache-2.0",
70
+ "optional": true,
71
+ "os": [
72
+ "darwin"
73
+ ],
74
+ "engines": {
75
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
76
+ },
77
+ "funding": {
78
+ "url": "https://opencollective.com/libvips"
79
+ },
80
+ "optionalDependencies": {
81
+ "@img/sharp-libvips-darwin-arm64": "1.0.4"
82
+ }
83
+ },
84
+ "node_modules/@img/sharp-darwin-x64": {
85
+ "version": "0.33.5",
86
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
87
+ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
88
+ "cpu": [
89
+ "x64"
90
+ ],
91
+ "license": "Apache-2.0",
92
+ "optional": true,
93
+ "os": [
94
+ "darwin"
95
+ ],
96
+ "engines": {
97
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
98
+ },
99
+ "funding": {
100
+ "url": "https://opencollective.com/libvips"
101
+ },
102
+ "optionalDependencies": {
103
+ "@img/sharp-libvips-darwin-x64": "1.0.4"
104
+ }
105
+ },
106
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
107
+ "version": "1.0.4",
108
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
109
+ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
110
+ "cpu": [
111
+ "arm64"
112
+ ],
113
+ "license": "LGPL-3.0-or-later",
114
+ "optional": true,
115
+ "os": [
116
+ "darwin"
117
+ ],
118
+ "funding": {
119
+ "url": "https://opencollective.com/libvips"
120
+ }
121
+ },
122
+ "node_modules/@img/sharp-libvips-darwin-x64": {
123
+ "version": "1.0.4",
124
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
125
+ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
126
+ "cpu": [
127
+ "x64"
128
+ ],
129
+ "license": "LGPL-3.0-or-later",
130
+ "optional": true,
131
+ "os": [
132
+ "darwin"
133
+ ],
134
+ "funding": {
135
+ "url": "https://opencollective.com/libvips"
136
+ }
137
+ },
138
+ "node_modules/@img/sharp-libvips-linux-arm": {
139
+ "version": "1.0.5",
140
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
141
+ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
142
+ "cpu": [
143
+ "arm"
144
+ ],
145
+ "license": "LGPL-3.0-or-later",
146
+ "optional": true,
147
+ "os": [
148
+ "linux"
149
+ ],
150
+ "funding": {
151
+ "url": "https://opencollective.com/libvips"
152
+ }
153
+ },
154
+ "node_modules/@img/sharp-libvips-linux-arm64": {
155
+ "version": "1.0.4",
156
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
157
+ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
158
+ "cpu": [
159
+ "arm64"
160
+ ],
161
+ "license": "LGPL-3.0-or-later",
162
+ "optional": true,
163
+ "os": [
164
+ "linux"
165
+ ],
166
+ "funding": {
167
+ "url": "https://opencollective.com/libvips"
168
+ }
169
+ },
170
+ "node_modules/@img/sharp-libvips-linux-s390x": {
171
+ "version": "1.0.4",
172
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
173
+ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
174
+ "cpu": [
175
+ "s390x"
176
+ ],
177
+ "license": "LGPL-3.0-or-later",
178
+ "optional": true,
179
+ "os": [
180
+ "linux"
181
+ ],
182
+ "funding": {
183
+ "url": "https://opencollective.com/libvips"
184
+ }
185
+ },
186
+ "node_modules/@img/sharp-libvips-linux-x64": {
187
+ "version": "1.0.4",
188
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
189
+ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
190
+ "cpu": [
191
+ "x64"
192
+ ],
193
+ "license": "LGPL-3.0-or-later",
194
+ "optional": true,
195
+ "os": [
196
+ "linux"
197
+ ],
198
+ "funding": {
199
+ "url": "https://opencollective.com/libvips"
200
+ }
201
+ },
202
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
203
+ "version": "1.0.4",
204
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
205
+ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
206
+ "cpu": [
207
+ "arm64"
208
+ ],
209
+ "license": "LGPL-3.0-or-later",
210
+ "optional": true,
211
+ "os": [
212
+ "linux"
213
+ ],
214
+ "funding": {
215
+ "url": "https://opencollective.com/libvips"
216
+ }
217
+ },
218
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
219
+ "version": "1.0.4",
220
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
221
+ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
222
+ "cpu": [
223
+ "x64"
224
+ ],
225
+ "license": "LGPL-3.0-or-later",
226
+ "optional": true,
227
+ "os": [
228
+ "linux"
229
+ ],
230
+ "funding": {
231
+ "url": "https://opencollective.com/libvips"
232
+ }
233
+ },
234
+ "node_modules/@img/sharp-linux-arm": {
235
+ "version": "0.33.5",
236
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
237
+ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
238
+ "cpu": [
239
+ "arm"
240
+ ],
241
+ "license": "Apache-2.0",
242
+ "optional": true,
243
+ "os": [
244
+ "linux"
245
+ ],
246
+ "engines": {
247
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
248
+ },
249
+ "funding": {
250
+ "url": "https://opencollective.com/libvips"
251
+ },
252
+ "optionalDependencies": {
253
+ "@img/sharp-libvips-linux-arm": "1.0.5"
254
+ }
255
+ },
256
+ "node_modules/@img/sharp-linux-arm64": {
257
+ "version": "0.33.5",
258
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
259
+ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
260
+ "cpu": [
261
+ "arm64"
262
+ ],
263
+ "license": "Apache-2.0",
264
+ "optional": true,
265
+ "os": [
266
+ "linux"
267
+ ],
268
+ "engines": {
269
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
270
+ },
271
+ "funding": {
272
+ "url": "https://opencollective.com/libvips"
273
+ },
274
+ "optionalDependencies": {
275
+ "@img/sharp-libvips-linux-arm64": "1.0.4"
276
+ }
277
+ },
278
+ "node_modules/@img/sharp-linux-s390x": {
279
+ "version": "0.33.5",
280
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
281
+ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
282
+ "cpu": [
283
+ "s390x"
284
+ ],
285
+ "license": "Apache-2.0",
286
+ "optional": true,
287
+ "os": [
288
+ "linux"
289
+ ],
290
+ "engines": {
291
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
292
+ },
293
+ "funding": {
294
+ "url": "https://opencollective.com/libvips"
295
+ },
296
+ "optionalDependencies": {
297
+ "@img/sharp-libvips-linux-s390x": "1.0.4"
298
+ }
299
+ },
300
+ "node_modules/@img/sharp-linux-x64": {
301
+ "version": "0.33.5",
302
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
303
+ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
304
+ "cpu": [
305
+ "x64"
306
+ ],
307
+ "license": "Apache-2.0",
308
+ "optional": true,
309
+ "os": [
310
+ "linux"
311
+ ],
312
+ "engines": {
313
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
314
+ },
315
+ "funding": {
316
+ "url": "https://opencollective.com/libvips"
317
+ },
318
+ "optionalDependencies": {
319
+ "@img/sharp-libvips-linux-x64": "1.0.4"
320
+ }
321
+ },
322
+ "node_modules/@img/sharp-linuxmusl-arm64": {
323
+ "version": "0.33.5",
324
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
325
+ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
326
+ "cpu": [
327
+ "arm64"
328
+ ],
329
+ "license": "Apache-2.0",
330
+ "optional": true,
331
+ "os": [
332
+ "linux"
333
+ ],
334
+ "engines": {
335
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
336
+ },
337
+ "funding": {
338
+ "url": "https://opencollective.com/libvips"
339
+ },
340
+ "optionalDependencies": {
341
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
342
+ }
343
+ },
344
+ "node_modules/@img/sharp-linuxmusl-x64": {
345
+ "version": "0.33.5",
346
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
347
+ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
348
+ "cpu": [
349
+ "x64"
350
+ ],
351
+ "license": "Apache-2.0",
352
+ "optional": true,
353
+ "os": [
354
+ "linux"
355
+ ],
356
+ "engines": {
357
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
358
+ },
359
+ "funding": {
360
+ "url": "https://opencollective.com/libvips"
361
+ },
362
+ "optionalDependencies": {
363
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
364
+ }
365
+ },
366
+ "node_modules/@img/sharp-wasm32": {
367
+ "version": "0.33.5",
368
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
369
+ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
370
+ "cpu": [
371
+ "wasm32"
372
+ ],
373
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
374
+ "optional": true,
375
+ "dependencies": {
376
+ "@emnapi/runtime": "^1.2.0"
377
+ },
378
+ "engines": {
379
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
380
+ },
381
+ "funding": {
382
+ "url": "https://opencollective.com/libvips"
383
+ }
384
+ },
385
+ "node_modules/@img/sharp-win32-ia32": {
386
+ "version": "0.33.5",
387
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
388
+ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
389
+ "cpu": [
390
+ "ia32"
391
+ ],
392
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
393
+ "optional": true,
394
+ "os": [
395
+ "win32"
396
+ ],
397
+ "engines": {
398
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
399
+ },
400
+ "funding": {
401
+ "url": "https://opencollective.com/libvips"
402
+ }
403
+ },
404
+ "node_modules/@img/sharp-win32-x64": {
405
+ "version": "0.33.5",
406
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
407
+ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
408
+ "cpu": [
409
+ "x64"
410
+ ],
411
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
412
+ "optional": true,
413
+ "os": [
414
+ "win32"
415
+ ],
416
+ "engines": {
417
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
418
+ },
419
+ "funding": {
420
+ "url": "https://opencollective.com/libvips"
421
+ }
422
+ },
423
+ "node_modules/@next/env": {
424
+ "version": "15.2.3",
425
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.3.tgz",
426
+ "integrity": "sha512-a26KnbW9DFEUsSxAxKBORR/uD9THoYoKbkpFywMN/AFvboTt94b8+g/07T8J6ACsdLag8/PDU60ov4rPxRAixw==",
427
+ "license": "MIT"
428
+ },
429
+ "node_modules/@next/swc-darwin-arm64": {
430
+ "version": "15.2.3",
431
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.3.tgz",
432
+ "integrity": "sha512-uaBhA8aLbXLqwjnsHSkxs353WrRgQgiFjduDpc7YXEU0B54IKx3vU+cxQlYwPCyC8uYEEX7THhtQQsfHnvv8dw==",
433
+ "cpu": [
434
+ "arm64"
435
+ ],
436
+ "license": "MIT",
437
+ "optional": true,
438
+ "os": [
439
+ "darwin"
440
+ ],
441
+ "engines": {
442
+ "node": ">= 10"
443
+ }
444
+ },
445
+ "node_modules/@next/swc-darwin-x64": {
446
+ "version": "15.2.3",
447
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.3.tgz",
448
+ "integrity": "sha512-pVwKvJ4Zk7h+4hwhqOUuMx7Ib02u3gDX3HXPKIShBi9JlYllI0nU6TWLbPT94dt7FSi6mSBhfc2JrHViwqbOdw==",
449
+ "cpu": [
450
+ "x64"
451
+ ],
452
+ "license": "MIT",
453
+ "optional": true,
454
+ "os": [
455
+ "darwin"
456
+ ],
457
+ "engines": {
458
+ "node": ">= 10"
459
+ }
460
+ },
461
+ "node_modules/@next/swc-linux-arm64-gnu": {
462
+ "version": "15.2.3",
463
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.3.tgz",
464
+ "integrity": "sha512-50ibWdn2RuFFkOEUmo9NCcQbbV9ViQOrUfG48zHBCONciHjaUKtHcYFiCwBVuzD08fzvzkWuuZkd4AqbvKO7UQ==",
465
+ "cpu": [
466
+ "arm64"
467
+ ],
468
+ "license": "MIT",
469
+ "optional": true,
470
+ "os": [
471
+ "linux"
472
+ ],
473
+ "engines": {
474
+ "node": ">= 10"
475
+ }
476
+ },
477
+ "node_modules/@next/swc-linux-arm64-musl": {
478
+ "version": "15.2.3",
479
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.3.tgz",
480
+ "integrity": "sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q==",
481
+ "cpu": [
482
+ "arm64"
483
+ ],
484
+ "license": "MIT",
485
+ "optional": true,
486
+ "os": [
487
+ "linux"
488
+ ],
489
+ "engines": {
490
+ "node": ">= 10"
491
+ }
492
+ },
493
+ "node_modules/@next/swc-linux-x64-gnu": {
494
+ "version": "15.2.3",
495
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.3.tgz",
496
+ "integrity": "sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w==",
497
+ "cpu": [
498
+ "x64"
499
+ ],
500
+ "license": "MIT",
501
+ "optional": true,
502
+ "os": [
503
+ "linux"
504
+ ],
505
+ "engines": {
506
+ "node": ">= 10"
507
+ }
508
+ },
509
+ "node_modules/@next/swc-linux-x64-musl": {
510
+ "version": "15.2.3",
511
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.3.tgz",
512
+ "integrity": "sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg==",
513
+ "cpu": [
514
+ "x64"
515
+ ],
516
+ "license": "MIT",
517
+ "optional": true,
518
+ "os": [
519
+ "linux"
520
+ ],
521
+ "engines": {
522
+ "node": ">= 10"
523
+ }
524
+ },
525
+ "node_modules/@next/swc-win32-arm64-msvc": {
526
+ "version": "15.2.3",
527
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.3.tgz",
528
+ "integrity": "sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q==",
529
+ "cpu": [
530
+ "arm64"
531
+ ],
532
+ "license": "MIT",
533
+ "optional": true,
534
+ "os": [
535
+ "win32"
536
+ ],
537
+ "engines": {
538
+ "node": ">= 10"
539
+ }
540
+ },
541
+ "node_modules/@next/swc-win32-x64-msvc": {
542
+ "version": "15.2.3",
543
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.3.tgz",
544
+ "integrity": "sha512-gHYS9tc+G2W0ZC8rBL+H6RdtXIyk40uLiaos0yj5US85FNhbFEndMA2nW3z47nzOWiSvXTZ5kBClc3rD0zJg0w==",
545
+ "cpu": [
546
+ "x64"
547
+ ],
548
+ "license": "MIT",
549
+ "optional": true,
550
+ "os": [
551
+ "win32"
552
+ ],
553
+ "engines": {
554
+ "node": ">= 10"
555
+ }
556
+ },
557
+ "node_modules/@swc/counter": {
558
+ "version": "0.1.3",
559
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
560
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
561
+ "license": "Apache-2.0"
562
+ },
563
+ "node_modules/@swc/helpers": {
564
+ "version": "0.5.15",
565
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
566
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
567
+ "license": "Apache-2.0",
568
+ "dependencies": {
569
+ "tslib": "^2.8.0"
570
+ }
571
+ },
572
+ "node_modules/@tailwindcss/node": {
573
+ "version": "4.0.15",
574
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.15.tgz",
575
+ "integrity": "sha512-IODaJjNmiasfZX3IoS+4Em3iu0fD2HS0/tgrnkYfW4hyUor01Smnr5eY3jc4rRgaTDrJlDmBTHbFO0ETTDaxWA==",
576
+ "dev": true,
577
+ "license": "MIT",
578
+ "dependencies": {
579
+ "enhanced-resolve": "^5.18.1",
580
+ "jiti": "^2.4.2",
581
+ "tailwindcss": "4.0.15"
582
+ }
583
+ },
584
+ "node_modules/@tailwindcss/oxide": {
585
+ "version": "4.0.15",
586
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.15.tgz",
587
+ "integrity": "sha512-e0uHrKfPu7JJGMfjwVNyt5M0u+OP8kUmhACwIRlM+JNBuReDVQ63yAD1NWe5DwJtdaHjugNBil76j+ks3zlk6g==",
588
+ "dev": true,
589
+ "license": "MIT",
590
+ "engines": {
591
+ "node": ">= 10"
592
+ },
593
+ "optionalDependencies": {
594
+ "@tailwindcss/oxide-android-arm64": "4.0.15",
595
+ "@tailwindcss/oxide-darwin-arm64": "4.0.15",
596
+ "@tailwindcss/oxide-darwin-x64": "4.0.15",
597
+ "@tailwindcss/oxide-freebsd-x64": "4.0.15",
598
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.15",
599
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.0.15",
600
+ "@tailwindcss/oxide-linux-arm64-musl": "4.0.15",
601
+ "@tailwindcss/oxide-linux-x64-gnu": "4.0.15",
602
+ "@tailwindcss/oxide-linux-x64-musl": "4.0.15",
603
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.0.15",
604
+ "@tailwindcss/oxide-win32-x64-msvc": "4.0.15"
605
+ }
606
+ },
607
+ "node_modules/@tailwindcss/oxide-android-arm64": {
608
+ "version": "4.0.15",
609
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.15.tgz",
610
+ "integrity": "sha512-EBuyfSKkom7N+CB3A+7c0m4+qzKuiN0WCvzPvj5ZoRu4NlQadg/mthc1tl5k9b5ffRGsbDvP4k21azU4VwVk3Q==",
611
+ "cpu": [
612
+ "arm64"
613
+ ],
614
+ "dev": true,
615
+ "license": "MIT",
616
+ "optional": true,
617
+ "os": [
618
+ "android"
619
+ ],
620
+ "engines": {
621
+ "node": ">= 10"
622
+ }
623
+ },
624
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
625
+ "version": "4.0.15",
626
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.15.tgz",
627
+ "integrity": "sha512-ObVAnEpLepMhV9VoO0JSit66jiN5C4YCqW3TflsE9boo2Z7FIjV80RFbgeL2opBhtxbaNEDa6D0/hq/EP03kgQ==",
628
+ "cpu": [
629
+ "arm64"
630
+ ],
631
+ "dev": true,
632
+ "license": "MIT",
633
+ "optional": true,
634
+ "os": [
635
+ "darwin"
636
+ ],
637
+ "engines": {
638
+ "node": ">= 10"
639
+ }
640
+ },
641
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
642
+ "version": "4.0.15",
643
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.15.tgz",
644
+ "integrity": "sha512-IElwoFhUinOr9MyKmGTPNi1Rwdh68JReFgYWibPWTGuevkHkLWKEflZc2jtI5lWZ5U9JjUnUfnY43I4fEXrc4g==",
645
+ "cpu": [
646
+ "x64"
647
+ ],
648
+ "dev": true,
649
+ "license": "MIT",
650
+ "optional": true,
651
+ "os": [
652
+ "darwin"
653
+ ],
654
+ "engines": {
655
+ "node": ">= 10"
656
+ }
657
+ },
658
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
659
+ "version": "4.0.15",
660
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.15.tgz",
661
+ "integrity": "sha512-6BLLqyx7SIYRBOnTZ8wgfXANLJV5TQd3PevRJZp0vn42eO58A2LykRKdvL1qyPfdpmEVtF+uVOEZ4QTMqDRAWA==",
662
+ "cpu": [
663
+ "x64"
664
+ ],
665
+ "dev": true,
666
+ "license": "MIT",
667
+ "optional": true,
668
+ "os": [
669
+ "freebsd"
670
+ ],
671
+ "engines": {
672
+ "node": ">= 10"
673
+ }
674
+ },
675
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
676
+ "version": "4.0.15",
677
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.15.tgz",
678
+ "integrity": "sha512-Zy63EVqO9241Pfg6G0IlRIWyY5vNcWrL5dd2WAKVJZRQVeolXEf1KfjkyeAAlErDj72cnyXObEZjMoPEKHpdNw==",
679
+ "cpu": [
680
+ "arm"
681
+ ],
682
+ "dev": true,
683
+ "license": "MIT",
684
+ "optional": true,
685
+ "os": [
686
+ "linux"
687
+ ],
688
+ "engines": {
689
+ "node": ">= 10"
690
+ }
691
+ },
692
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
693
+ "version": "4.0.15",
694
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.15.tgz",
695
+ "integrity": "sha512-2NemGQeaTbtIp1Z2wyerbVEJZTkAWhMDOhhR5z/zJ75yMNf8yLnE+sAlyf6yGDNr+1RqvWrRhhCFt7i0CIxe4Q==",
696
+ "cpu": [
697
+ "arm64"
698
+ ],
699
+ "dev": true,
700
+ "license": "MIT",
701
+ "optional": true,
702
+ "os": [
703
+ "linux"
704
+ ],
705
+ "engines": {
706
+ "node": ">= 10"
707
+ }
708
+ },
709
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
710
+ "version": "4.0.15",
711
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.15.tgz",
712
+ "integrity": "sha512-342GVnhH/6PkVgKtEzvNVuQ4D+Q7B7qplvuH20Cfz9qEtydG6IQczTZ5IT4JPlh931MG1NUCVxg+CIorr1WJyw==",
713
+ "cpu": [
714
+ "arm64"
715
+ ],
716
+ "dev": true,
717
+ "license": "MIT",
718
+ "optional": true,
719
+ "os": [
720
+ "linux"
721
+ ],
722
+ "engines": {
723
+ "node": ">= 10"
724
+ }
725
+ },
726
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
727
+ "version": "4.0.15",
728
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.15.tgz",
729
+ "integrity": "sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==",
730
+ "cpu": [
731
+ "x64"
732
+ ],
733
+ "dev": true,
734
+ "license": "MIT",
735
+ "optional": true,
736
+ "os": [
737
+ "linux"
738
+ ],
739
+ "engines": {
740
+ "node": ">= 10"
741
+ }
742
+ },
743
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
744
+ "version": "4.0.15",
745
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.15.tgz",
746
+ "integrity": "sha512-Gg/Y1XrKEvKpq6WeNt2h8rMIKOBj/W3mNa5NMvkQgMC7iO0+UNLrYmt6zgZufht66HozNpn+tJMbbkZ5a3LczA==",
747
+ "cpu": [
748
+ "x64"
749
+ ],
750
+ "dev": true,
751
+ "license": "MIT",
752
+ "optional": true,
753
+ "os": [
754
+ "linux"
755
+ ],
756
+ "engines": {
757
+ "node": ">= 10"
758
+ }
759
+ },
760
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
761
+ "version": "4.0.15",
762
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.15.tgz",
763
+ "integrity": "sha512-7QtSSJwYZ7ZK1phVgcNZpuf7c7gaCj8Wb0xjliligT5qCGCp79OV2n3SJummVZdw4fbTNKUOYMO7m1GinppZyA==",
764
+ "cpu": [
765
+ "arm64"
766
+ ],
767
+ "dev": true,
768
+ "license": "MIT",
769
+ "optional": true,
770
+ "os": [
771
+ "win32"
772
+ ],
773
+ "engines": {
774
+ "node": ">= 10"
775
+ }
776
+ },
777
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
778
+ "version": "4.0.15",
779
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.15.tgz",
780
+ "integrity": "sha512-JQ5H+5MLhOjpgNp6KomouE0ZuKmk3hO5h7/ClMNAQ8gZI2zkli3IH8ZqLbd2DVfXDbdxN2xvooIEeIlkIoSCqw==",
781
+ "cpu": [
782
+ "x64"
783
+ ],
784
+ "dev": true,
785
+ "license": "MIT",
786
+ "optional": true,
787
+ "os": [
788
+ "win32"
789
+ ],
790
+ "engines": {
791
+ "node": ">= 10"
792
+ }
793
+ },
794
+ "node_modules/@tailwindcss/postcss": {
795
+ "version": "4.0.15",
796
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.0.15.tgz",
797
+ "integrity": "sha512-qyrpoDKIO7wzkRbKCvGLo7gXRjT9/Njf7ZJiJhG4njrfZkvOhjwnaHpYbpxYeDysEg+9pB1R4jcd+vQ7ZUDsmQ==",
798
+ "dev": true,
799
+ "license": "MIT",
800
+ "dependencies": {
801
+ "@alloc/quick-lru": "^5.2.0",
802
+ "@tailwindcss/node": "4.0.15",
803
+ "@tailwindcss/oxide": "4.0.15",
804
+ "lightningcss": "1.29.2",
805
+ "postcss": "^8.4.41",
806
+ "tailwindcss": "4.0.15"
807
+ }
808
+ },
809
+ "node_modules/@types/node": {
810
+ "version": "22.13.14",
811
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
812
+ "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==",
813
+ "dev": true,
814
+ "license": "MIT",
815
+ "dependencies": {
816
+ "undici-types": "~6.20.0"
817
+ }
818
+ },
819
+ "node_modules/@types/prismjs": {
820
+ "version": "1.26.5",
821
+ "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
822
+ "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
823
+ "dev": true,
824
+ "license": "MIT"
825
+ },
826
+ "node_modules/@types/react": {
827
+ "version": "19.0.12",
828
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz",
829
+ "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
830
+ "dev": true,
831
+ "license": "MIT",
832
+ "dependencies": {
833
+ "csstype": "^3.0.2"
834
+ }
835
+ },
836
+ "node_modules/@types/react-dom": {
837
+ "version": "19.0.4",
838
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz",
839
+ "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==",
840
+ "license": "MIT",
841
+ "peerDependencies": {
842
+ "@types/react": "^19.0.0"
843
+ }
844
+ },
845
+ "node_modules/busboy": {
846
+ "version": "1.6.0",
847
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
848
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
849
+ "dependencies": {
850
+ "streamsearch": "^1.1.0"
851
+ },
852
+ "engines": {
853
+ "node": ">=10.16.0"
854
+ }
855
+ },
856
+ "node_modules/caniuse-lite": {
857
+ "version": "1.0.30001707",
858
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
859
+ "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
860
+ "funding": [
861
+ {
862
+ "type": "opencollective",
863
+ "url": "https://opencollective.com/browserslist"
864
+ },
865
+ {
866
+ "type": "tidelift",
867
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
868
+ },
869
+ {
870
+ "type": "github",
871
+ "url": "https://github.com/sponsors/ai"
872
+ }
873
+ ],
874
+ "license": "CC-BY-4.0"
875
+ },
876
+ "node_modules/client-only": {
877
+ "version": "0.0.1",
878
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
879
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
880
+ "license": "MIT"
881
+ },
882
+ "node_modules/clsx": {
883
+ "version": "2.1.1",
884
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
885
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
886
+ "dev": true,
887
+ "license": "MIT",
888
+ "engines": {
889
+ "node": ">=6"
890
+ }
891
+ },
892
+ "node_modules/color": {
893
+ "version": "4.2.3",
894
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
895
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
896
+ "license": "MIT",
897
+ "optional": true,
898
+ "dependencies": {
899
+ "color-convert": "^2.0.1",
900
+ "color-string": "^1.9.0"
901
+ },
902
+ "engines": {
903
+ "node": ">=12.5.0"
904
+ }
905
+ },
906
+ "node_modules/color-convert": {
907
+ "version": "2.0.1",
908
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
909
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
910
+ "license": "MIT",
911
+ "optional": true,
912
+ "dependencies": {
913
+ "color-name": "~1.1.4"
914
+ },
915
+ "engines": {
916
+ "node": ">=7.0.0"
917
+ }
918
+ },
919
+ "node_modules/color-name": {
920
+ "version": "1.1.4",
921
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
922
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
923
+ "license": "MIT",
924
+ "optional": true
925
+ },
926
+ "node_modules/color-string": {
927
+ "version": "1.9.1",
928
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
929
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
930
+ "license": "MIT",
931
+ "optional": true,
932
+ "dependencies": {
933
+ "color-name": "^1.0.0",
934
+ "simple-swizzle": "^0.2.2"
935
+ }
936
+ },
937
+ "node_modules/csstype": {
938
+ "version": "3.1.3",
939
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
940
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
941
+ "license": "MIT"
942
+ },
943
+ "node_modules/detect-libc": {
944
+ "version": "2.0.3",
945
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
946
+ "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
947
+ "devOptional": true,
948
+ "license": "Apache-2.0",
949
+ "engines": {
950
+ "node": ">=8"
951
+ }
952
+ },
953
+ "node_modules/enhanced-resolve": {
954
+ "version": "5.18.1",
955
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
956
+ "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
957
+ "dev": true,
958
+ "license": "MIT",
959
+ "dependencies": {
960
+ "graceful-fs": "^4.2.4",
961
+ "tapable": "^2.2.0"
962
+ },
963
+ "engines": {
964
+ "node": ">=10.13.0"
965
+ }
966
+ },
967
+ "node_modules/goober": {
968
+ "version": "2.1.16",
969
+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
970
+ "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
971
+ "license": "MIT",
972
+ "peerDependencies": {
973
+ "csstype": "^3.0.10"
974
+ }
975
+ },
976
+ "node_modules/graceful-fs": {
977
+ "version": "4.2.11",
978
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
979
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
980
+ "dev": true,
981
+ "license": "ISC"
982
+ },
983
+ "node_modules/is-arrayish": {
984
+ "version": "0.3.2",
985
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
986
+ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
987
+ "license": "MIT",
988
+ "optional": true
989
+ },
990
+ "node_modules/jiti": {
991
+ "version": "2.4.2",
992
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
993
+ "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
994
+ "dev": true,
995
+ "license": "MIT",
996
+ "bin": {
997
+ "jiti": "lib/jiti-cli.mjs"
998
+ }
999
+ },
1000
+ "node_modules/lightningcss": {
1001
+ "version": "1.29.2",
1002
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
1003
+ "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==",
1004
+ "dev": true,
1005
+ "license": "MPL-2.0",
1006
+ "dependencies": {
1007
+ "detect-libc": "^2.0.3"
1008
+ },
1009
+ "engines": {
1010
+ "node": ">= 12.0.0"
1011
+ },
1012
+ "funding": {
1013
+ "type": "opencollective",
1014
+ "url": "https://opencollective.com/parcel"
1015
+ },
1016
+ "optionalDependencies": {
1017
+ "lightningcss-darwin-arm64": "1.29.2",
1018
+ "lightningcss-darwin-x64": "1.29.2",
1019
+ "lightningcss-freebsd-x64": "1.29.2",
1020
+ "lightningcss-linux-arm-gnueabihf": "1.29.2",
1021
+ "lightningcss-linux-arm64-gnu": "1.29.2",
1022
+ "lightningcss-linux-arm64-musl": "1.29.2",
1023
+ "lightningcss-linux-x64-gnu": "1.29.2",
1024
+ "lightningcss-linux-x64-musl": "1.29.2",
1025
+ "lightningcss-win32-arm64-msvc": "1.29.2",
1026
+ "lightningcss-win32-x64-msvc": "1.29.2"
1027
+ }
1028
+ },
1029
+ "node_modules/lightningcss-darwin-arm64": {
1030
+ "version": "1.29.2",
1031
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz",
1032
+ "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
1033
+ "cpu": [
1034
+ "arm64"
1035
+ ],
1036
+ "dev": true,
1037
+ "license": "MPL-2.0",
1038
+ "optional": true,
1039
+ "os": [
1040
+ "darwin"
1041
+ ],
1042
+ "engines": {
1043
+ "node": ">= 12.0.0"
1044
+ },
1045
+ "funding": {
1046
+ "type": "opencollective",
1047
+ "url": "https://opencollective.com/parcel"
1048
+ }
1049
+ },
1050
+ "node_modules/lightningcss-darwin-x64": {
1051
+ "version": "1.29.2",
1052
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz",
1053
+ "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==",
1054
+ "cpu": [
1055
+ "x64"
1056
+ ],
1057
+ "dev": true,
1058
+ "license": "MPL-2.0",
1059
+ "optional": true,
1060
+ "os": [
1061
+ "darwin"
1062
+ ],
1063
+ "engines": {
1064
+ "node": ">= 12.0.0"
1065
+ },
1066
+ "funding": {
1067
+ "type": "opencollective",
1068
+ "url": "https://opencollective.com/parcel"
1069
+ }
1070
+ },
1071
+ "node_modules/lightningcss-freebsd-x64": {
1072
+ "version": "1.29.2",
1073
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz",
1074
+ "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==",
1075
+ "cpu": [
1076
+ "x64"
1077
+ ],
1078
+ "dev": true,
1079
+ "license": "MPL-2.0",
1080
+ "optional": true,
1081
+ "os": [
1082
+ "freebsd"
1083
+ ],
1084
+ "engines": {
1085
+ "node": ">= 12.0.0"
1086
+ },
1087
+ "funding": {
1088
+ "type": "opencollective",
1089
+ "url": "https://opencollective.com/parcel"
1090
+ }
1091
+ },
1092
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
1093
+ "version": "1.29.2",
1094
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz",
1095
+ "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==",
1096
+ "cpu": [
1097
+ "arm"
1098
+ ],
1099
+ "dev": true,
1100
+ "license": "MPL-2.0",
1101
+ "optional": true,
1102
+ "os": [
1103
+ "linux"
1104
+ ],
1105
+ "engines": {
1106
+ "node": ">= 12.0.0"
1107
+ },
1108
+ "funding": {
1109
+ "type": "opencollective",
1110
+ "url": "https://opencollective.com/parcel"
1111
+ }
1112
+ },
1113
+ "node_modules/lightningcss-linux-arm64-gnu": {
1114
+ "version": "1.29.2",
1115
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz",
1116
+ "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==",
1117
+ "cpu": [
1118
+ "arm64"
1119
+ ],
1120
+ "dev": true,
1121
+ "license": "MPL-2.0",
1122
+ "optional": true,
1123
+ "os": [
1124
+ "linux"
1125
+ ],
1126
+ "engines": {
1127
+ "node": ">= 12.0.0"
1128
+ },
1129
+ "funding": {
1130
+ "type": "opencollective",
1131
+ "url": "https://opencollective.com/parcel"
1132
+ }
1133
+ },
1134
+ "node_modules/lightningcss-linux-arm64-musl": {
1135
+ "version": "1.29.2",
1136
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz",
1137
+ "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==",
1138
+ "cpu": [
1139
+ "arm64"
1140
+ ],
1141
+ "dev": true,
1142
+ "license": "MPL-2.0",
1143
+ "optional": true,
1144
+ "os": [
1145
+ "linux"
1146
+ ],
1147
+ "engines": {
1148
+ "node": ">= 12.0.0"
1149
+ },
1150
+ "funding": {
1151
+ "type": "opencollective",
1152
+ "url": "https://opencollective.com/parcel"
1153
+ }
1154
+ },
1155
+ "node_modules/lightningcss-linux-x64-gnu": {
1156
+ "version": "1.29.2",
1157
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz",
1158
+ "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==",
1159
+ "cpu": [
1160
+ "x64"
1161
+ ],
1162
+ "dev": true,
1163
+ "license": "MPL-2.0",
1164
+ "optional": true,
1165
+ "os": [
1166
+ "linux"
1167
+ ],
1168
+ "engines": {
1169
+ "node": ">= 12.0.0"
1170
+ },
1171
+ "funding": {
1172
+ "type": "opencollective",
1173
+ "url": "https://opencollective.com/parcel"
1174
+ }
1175
+ },
1176
+ "node_modules/lightningcss-linux-x64-musl": {
1177
+ "version": "1.29.2",
1178
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz",
1179
+ "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==",
1180
+ "cpu": [
1181
+ "x64"
1182
+ ],
1183
+ "dev": true,
1184
+ "license": "MPL-2.0",
1185
+ "optional": true,
1186
+ "os": [
1187
+ "linux"
1188
+ ],
1189
+ "engines": {
1190
+ "node": ">= 12.0.0"
1191
+ },
1192
+ "funding": {
1193
+ "type": "opencollective",
1194
+ "url": "https://opencollective.com/parcel"
1195
+ }
1196
+ },
1197
+ "node_modules/lightningcss-win32-arm64-msvc": {
1198
+ "version": "1.29.2",
1199
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz",
1200
+ "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==",
1201
+ "cpu": [
1202
+ "arm64"
1203
+ ],
1204
+ "dev": true,
1205
+ "license": "MPL-2.0",
1206
+ "optional": true,
1207
+ "os": [
1208
+ "win32"
1209
+ ],
1210
+ "engines": {
1211
+ "node": ">= 12.0.0"
1212
+ },
1213
+ "funding": {
1214
+ "type": "opencollective",
1215
+ "url": "https://opencollective.com/parcel"
1216
+ }
1217
+ },
1218
+ "node_modules/lightningcss-win32-x64-msvc": {
1219
+ "version": "1.29.2",
1220
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz",
1221
+ "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==",
1222
+ "cpu": [
1223
+ "x64"
1224
+ ],
1225
+ "dev": true,
1226
+ "license": "MPL-2.0",
1227
+ "optional": true,
1228
+ "os": [
1229
+ "win32"
1230
+ ],
1231
+ "engines": {
1232
+ "node": ">= 12.0.0"
1233
+ },
1234
+ "funding": {
1235
+ "type": "opencollective",
1236
+ "url": "https://opencollective.com/parcel"
1237
+ }
1238
+ },
1239
+ "node_modules/lucide-react": {
1240
+ "version": "0.483.0",
1241
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.483.0.tgz",
1242
+ "integrity": "sha512-WldsY17Qb/T3VZdMnVQ9C3DDIP7h1ViDTHVdVGnLZcvHNg30zH/MTQ04RTORjexoGmpsXroiQXZ4QyR0kBy0FA==",
1243
+ "license": "ISC",
1244
+ "peerDependencies": {
1245
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
1246
+ }
1247
+ },
1248
+ "node_modules/nanoid": {
1249
+ "version": "3.3.11",
1250
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1251
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1252
+ "funding": [
1253
+ {
1254
+ "type": "github",
1255
+ "url": "https://github.com/sponsors/ai"
1256
+ }
1257
+ ],
1258
+ "license": "MIT",
1259
+ "bin": {
1260
+ "nanoid": "bin/nanoid.cjs"
1261
+ },
1262
+ "engines": {
1263
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1264
+ }
1265
+ },
1266
+ "node_modules/next": {
1267
+ "version": "15.2.3",
1268
+ "resolved": "https://registry.npmjs.org/next/-/next-15.2.3.tgz",
1269
+ "integrity": "sha512-x6eDkZxk2rPpu46E1ZVUWIBhYCLszmUY6fvHBFcbzJ9dD+qRX6vcHusaqqDlnY+VngKzKbAiG2iRCkPbmi8f7w==",
1270
+ "license": "MIT",
1271
+ "dependencies": {
1272
+ "@next/env": "15.2.3",
1273
+ "@swc/counter": "0.1.3",
1274
+ "@swc/helpers": "0.5.15",
1275
+ "busboy": "1.6.0",
1276
+ "caniuse-lite": "^1.0.30001579",
1277
+ "postcss": "8.4.31",
1278
+ "styled-jsx": "5.1.6"
1279
+ },
1280
+ "bin": {
1281
+ "next": "dist/bin/next"
1282
+ },
1283
+ "engines": {
1284
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
1285
+ },
1286
+ "optionalDependencies": {
1287
+ "@next/swc-darwin-arm64": "15.2.3",
1288
+ "@next/swc-darwin-x64": "15.2.3",
1289
+ "@next/swc-linux-arm64-gnu": "15.2.3",
1290
+ "@next/swc-linux-arm64-musl": "15.2.3",
1291
+ "@next/swc-linux-x64-gnu": "15.2.3",
1292
+ "@next/swc-linux-x64-musl": "15.2.3",
1293
+ "@next/swc-win32-arm64-msvc": "15.2.3",
1294
+ "@next/swc-win32-x64-msvc": "15.2.3",
1295
+ "sharp": "^0.33.5"
1296
+ },
1297
+ "peerDependencies": {
1298
+ "@opentelemetry/api": "^1.1.0",
1299
+ "@playwright/test": "^1.41.2",
1300
+ "babel-plugin-react-compiler": "*",
1301
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
1302
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
1303
+ "sass": "^1.3.0"
1304
+ },
1305
+ "peerDependenciesMeta": {
1306
+ "@opentelemetry/api": {
1307
+ "optional": true
1308
+ },
1309
+ "@playwright/test": {
1310
+ "optional": true
1311
+ },
1312
+ "babel-plugin-react-compiler": {
1313
+ "optional": true
1314
+ },
1315
+ "sass": {
1316
+ "optional": true
1317
+ }
1318
+ }
1319
+ },
1320
+ "node_modules/next/node_modules/postcss": {
1321
+ "version": "8.4.31",
1322
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
1323
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
1324
+ "funding": [
1325
+ {
1326
+ "type": "opencollective",
1327
+ "url": "https://opencollective.com/postcss/"
1328
+ },
1329
+ {
1330
+ "type": "tidelift",
1331
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1332
+ },
1333
+ {
1334
+ "type": "github",
1335
+ "url": "https://github.com/sponsors/ai"
1336
+ }
1337
+ ],
1338
+ "license": "MIT",
1339
+ "dependencies": {
1340
+ "nanoid": "^3.3.6",
1341
+ "picocolors": "^1.0.0",
1342
+ "source-map-js": "^1.0.2"
1343
+ },
1344
+ "engines": {
1345
+ "node": "^10 || ^12 || >=14"
1346
+ }
1347
+ },
1348
+ "node_modules/picocolors": {
1349
+ "version": "1.1.1",
1350
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1351
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1352
+ "license": "ISC"
1353
+ },
1354
+ "node_modules/postcss": {
1355
+ "version": "8.5.3",
1356
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
1357
+ "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
1358
+ "dev": true,
1359
+ "funding": [
1360
+ {
1361
+ "type": "opencollective",
1362
+ "url": "https://opencollective.com/postcss/"
1363
+ },
1364
+ {
1365
+ "type": "tidelift",
1366
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1367
+ },
1368
+ {
1369
+ "type": "github",
1370
+ "url": "https://github.com/sponsors/ai"
1371
+ }
1372
+ ],
1373
+ "license": "MIT",
1374
+ "dependencies": {
1375
+ "nanoid": "^3.3.8",
1376
+ "picocolors": "^1.1.1",
1377
+ "source-map-js": "^1.2.1"
1378
+ },
1379
+ "engines": {
1380
+ "node": "^10 || ^12 || >=14"
1381
+ }
1382
+ },
1383
+ "node_modules/prism-react-renderer": {
1384
+ "version": "2.4.1",
1385
+ "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
1386
+ "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==",
1387
+ "dev": true,
1388
+ "license": "MIT",
1389
+ "dependencies": {
1390
+ "@types/prismjs": "^1.26.0",
1391
+ "clsx": "^2.0.0"
1392
+ },
1393
+ "peerDependencies": {
1394
+ "react": ">=16.0.0"
1395
+ }
1396
+ },
1397
+ "node_modules/react": {
1398
+ "version": "19.0.0",
1399
+ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
1400
+ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
1401
+ "license": "MIT",
1402
+ "engines": {
1403
+ "node": ">=0.10.0"
1404
+ }
1405
+ },
1406
+ "node_modules/react-dom": {
1407
+ "version": "19.0.0",
1408
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
1409
+ "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
1410
+ "license": "MIT",
1411
+ "dependencies": {
1412
+ "scheduler": "^0.25.0"
1413
+ },
1414
+ "peerDependencies": {
1415
+ "react": "^19.0.0"
1416
+ }
1417
+ },
1418
+ "node_modules/react-hot-toast": {
1419
+ "version": "2.5.2",
1420
+ "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
1421
+ "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==",
1422
+ "license": "MIT",
1423
+ "dependencies": {
1424
+ "csstype": "^3.1.3",
1425
+ "goober": "^2.1.16"
1426
+ },
1427
+ "engines": {
1428
+ "node": ">=10"
1429
+ },
1430
+ "peerDependencies": {
1431
+ "react": ">=16",
1432
+ "react-dom": ">=16"
1433
+ }
1434
+ },
1435
+ "node_modules/react-masonry-css": {
1436
+ "version": "1.0.16",
1437
+ "resolved": "https://registry.npmjs.org/react-masonry-css/-/react-masonry-css-1.0.16.tgz",
1438
+ "integrity": "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==",
1439
+ "license": "MIT",
1440
+ "peerDependencies": {
1441
+ "react": ">=16.0.0"
1442
+ }
1443
+ },
1444
+ "node_modules/scheduler": {
1445
+ "version": "0.25.0",
1446
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
1447
+ "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
1448
+ "license": "MIT"
1449
+ },
1450
+ "node_modules/semver": {
1451
+ "version": "7.7.1",
1452
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
1453
+ "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
1454
+ "license": "ISC",
1455
+ "optional": true,
1456
+ "bin": {
1457
+ "semver": "bin/semver.js"
1458
+ },
1459
+ "engines": {
1460
+ "node": ">=10"
1461
+ }
1462
+ },
1463
+ "node_modules/sharp": {
1464
+ "version": "0.33.5",
1465
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
1466
+ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
1467
+ "hasInstallScript": true,
1468
+ "license": "Apache-2.0",
1469
+ "optional": true,
1470
+ "dependencies": {
1471
+ "color": "^4.2.3",
1472
+ "detect-libc": "^2.0.3",
1473
+ "semver": "^7.6.3"
1474
+ },
1475
+ "engines": {
1476
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1477
+ },
1478
+ "funding": {
1479
+ "url": "https://opencollective.com/libvips"
1480
+ },
1481
+ "optionalDependencies": {
1482
+ "@img/sharp-darwin-arm64": "0.33.5",
1483
+ "@img/sharp-darwin-x64": "0.33.5",
1484
+ "@img/sharp-libvips-darwin-arm64": "1.0.4",
1485
+ "@img/sharp-libvips-darwin-x64": "1.0.4",
1486
+ "@img/sharp-libvips-linux-arm": "1.0.5",
1487
+ "@img/sharp-libvips-linux-arm64": "1.0.4",
1488
+ "@img/sharp-libvips-linux-s390x": "1.0.4",
1489
+ "@img/sharp-libvips-linux-x64": "1.0.4",
1490
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
1491
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
1492
+ "@img/sharp-linux-arm": "0.33.5",
1493
+ "@img/sharp-linux-arm64": "0.33.5",
1494
+ "@img/sharp-linux-s390x": "0.33.5",
1495
+ "@img/sharp-linux-x64": "0.33.5",
1496
+ "@img/sharp-linuxmusl-arm64": "0.33.5",
1497
+ "@img/sharp-linuxmusl-x64": "0.33.5",
1498
+ "@img/sharp-wasm32": "0.33.5",
1499
+ "@img/sharp-win32-ia32": "0.33.5",
1500
+ "@img/sharp-win32-x64": "0.33.5"
1501
+ }
1502
+ },
1503
+ "node_modules/simple-swizzle": {
1504
+ "version": "0.2.2",
1505
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
1506
+ "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
1507
+ "license": "MIT",
1508
+ "optional": true,
1509
+ "dependencies": {
1510
+ "is-arrayish": "^0.3.1"
1511
+ }
1512
+ },
1513
+ "node_modules/source-map-js": {
1514
+ "version": "1.2.1",
1515
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1516
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1517
+ "license": "BSD-3-Clause",
1518
+ "engines": {
1519
+ "node": ">=0.10.0"
1520
+ }
1521
+ },
1522
+ "node_modules/streamsearch": {
1523
+ "version": "1.1.0",
1524
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
1525
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
1526
+ "engines": {
1527
+ "node": ">=10.0.0"
1528
+ }
1529
+ },
1530
+ "node_modules/styled-jsx": {
1531
+ "version": "5.1.6",
1532
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
1533
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
1534
+ "license": "MIT",
1535
+ "dependencies": {
1536
+ "client-only": "0.0.1"
1537
+ },
1538
+ "engines": {
1539
+ "node": ">= 12.0.0"
1540
+ },
1541
+ "peerDependencies": {
1542
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
1543
+ },
1544
+ "peerDependenciesMeta": {
1545
+ "@babel/core": {
1546
+ "optional": true
1547
+ },
1548
+ "babel-plugin-macros": {
1549
+ "optional": true
1550
+ }
1551
+ }
1552
+ },
1553
+ "node_modules/tailwind-scrollbar": {
1554
+ "version": "4.0.1",
1555
+ "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.1.tgz",
1556
+ "integrity": "sha512-j2ZfUI7p8xmSQdlqaCxEb4Mha8ErvWjDVyu2Ke4IstWprQ/6TmIz1GSLE62vsTlXwnMLYhuvbFbIFzaJGOGtMg==",
1557
+ "dev": true,
1558
+ "license": "MIT",
1559
+ "dependencies": {
1560
+ "prism-react-renderer": "^2.4.1"
1561
+ },
1562
+ "engines": {
1563
+ "node": ">=12.13.0"
1564
+ },
1565
+ "peerDependencies": {
1566
+ "tailwindcss": "4.x"
1567
+ }
1568
+ },
1569
+ "node_modules/tailwindcss": {
1570
+ "version": "4.0.15",
1571
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.15.tgz",
1572
+ "integrity": "sha512-6ZMg+hHdMJpjpeCCFasX7K+U615U9D+7k5/cDK/iRwl6GptF24+I/AbKgOnXhVKePzrEyIXutLv36n4cRsq3Sg==",
1573
+ "dev": true,
1574
+ "license": "MIT"
1575
+ },
1576
+ "node_modules/tapable": {
1577
+ "version": "2.2.1",
1578
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
1579
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
1580
+ "dev": true,
1581
+ "license": "MIT",
1582
+ "engines": {
1583
+ "node": ">=6"
1584
+ }
1585
+ },
1586
+ "node_modules/tslib": {
1587
+ "version": "2.8.1",
1588
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1589
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1590
+ "license": "0BSD"
1591
+ },
1592
+ "node_modules/typescript": {
1593
+ "version": "5.8.2",
1594
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
1595
+ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
1596
+ "dev": true,
1597
+ "license": "Apache-2.0",
1598
+ "bin": {
1599
+ "tsc": "bin/tsc",
1600
+ "tsserver": "bin/tsserver"
1601
+ },
1602
+ "engines": {
1603
+ "node": ">=14.17"
1604
+ }
1605
+ },
1606
+ "node_modules/undici-types": {
1607
+ "version": "6.20.0",
1608
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
1609
+ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
1610
+ "dev": true,
1611
+ "license": "MIT"
1612
+ },
1613
+ "node_modules/use-debounce": {
1614
+ "version": "10.0.4",
1615
+ "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.4.tgz",
1616
+ "integrity": "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==",
1617
+ "license": "MIT",
1618
+ "engines": {
1619
+ "node": ">= 16.0.0"
1620
+ },
1621
+ "peerDependencies": {
1622
+ "react": "*"
1623
+ }
1624
+ }
1625
+ }
1626
+ }
package.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "native-image",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev --turbopack",
7
+ "dev:webpack": "next dev",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "lint": "next lint"
11
+ },
12
+ "dependencies": {
13
+ "@google/generative-ai": "^0.24.0",
14
+ "@types/react-dom": "^19.0.4",
15
+ "lucide-react": "^0.483.0",
16
+ "next": "15.2.3",
17
+ "react": "^19.0.0",
18
+ "react-dom": "^19.0.0",
19
+ "react-hot-toast": "^2.5.2",
20
+ "react-masonry-css": "^1.0.16",
21
+ "use-debounce": "^10.0.4"
22
+ },
23
+ "devDependencies": {
24
+ "@tailwindcss/postcss": "^4",
25
+ "@types/node": "22.13.14",
26
+ "@types/react": "19.0.12",
27
+ "tailwind-scrollbar": "^4.0.1",
28
+ "tailwindcss": "^4",
29
+ "typescript": "5.8.2"
30
+ }
31
+ }
pages/_app.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import "../styles/globals.css";
2
+
3
+ export default function App({ Component, pageProps }) {
4
+ return <Component {...pageProps} />;
5
+ }
pages/_document.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Html, Head, Main, NextScript } from "next/document";
2
+
3
+ export default function Document() {
4
+ return (
5
+ <Html lang="en">
6
+ <Head />
7
+ <body className="antialiased">
8
+ <Main />
9
+ <NextScript />
10
+ </body>
11
+ </Html>
12
+ );
13
+ }
pages/api/analyze-image.js ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+
3
+ // Initialize Gemini API with error handling
4
+ const initializeGeminiAI = () => {
5
+ const apiKey = process.env.GEMINI_API_KEY;
6
+ if (!apiKey) {
7
+ throw new Error('GEMINI_API_KEY is not set in environment variables');
8
+ }
9
+ return new GoogleGenerativeAI(apiKey);
10
+ };
11
+
12
+ // Configure API route options
13
+ export const config = {
14
+ api: {
15
+ bodyParser: {
16
+ sizeLimit: '10mb' // Increase the body size limit to 10MB for larger images
17
+ }
18
+ }
19
+ };
20
+
21
+ export default async function handler(req, res) {
22
+ // Only allow POST requests
23
+ if (req.method !== 'POST') {
24
+ res.setHeader('Allow', ['POST']);
25
+ return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
26
+ }
27
+
28
+ try {
29
+ const { image, customApiKey } = req.body;
30
+ if (!image) {
31
+ return res.status(400).json({ error: 'Image is required' });
32
+ }
33
+
34
+ // Extract base64 data
35
+ const base64Data = image.split(',')[1];
36
+ if (!base64Data) {
37
+ return res.status(400).json({ error: 'Invalid image format' });
38
+ }
39
+
40
+ // Initialize Gemini with the appropriate API key
41
+ const apiKey = customApiKey || process.env.GEMINI_API_KEY;
42
+ const genAI = new GoogleGenerativeAI(apiKey);
43
+ const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
44
+
45
+ // Prepare the image parts
46
+ const imageParts = [
47
+ {
48
+ inlineData: {
49
+ data: base64Data,
50
+ mimeType: "image/jpeg"
51
+ }
52
+ }
53
+ ];
54
+
55
+ // Create the prompt - Updated for clarity and better results
56
+ const prompt = `Analyze this reference image/moodboard and create a detailed material prompt for a 3D rendering that captures its essence and visual qualities.
57
+ The prompt should follow this format and style, but be unique and creative:
58
+
59
+ Example 1: "Recreate this doodle as a physical chrome sculpture made of chromium metal tubes or pipes in a professional studio setting. If it is typography, render it accordingly, but always have a black background and studio lighting. Render it in Cinema 4D with Octane, using studio lighting against a pure black background. Make it look like a high-end product rendering of a sculptural piece."
60
+
61
+ Example 2: "Convert this drawing / text into a soft body physics render. Render it as if made of a soft, jelly-like material that responds to gravity and motion. Add realistic deformation, bounce, and squash effects typical of soft body dynamics. Use dramatic lighting against a black background to emphasize the material's translucency and surface properties. Make it look like a high-end 3D animation frame"
62
+
63
+ Create a new, unique prompt based on the visual qualities of the uploaded image that follows a similar style but is completely different from the examples.
64
+ Focus on:
65
+ 1. Material properties (metallic, glass, fabric, liquid, etc.)
66
+ 2. Lighting and environment (studio, dramatic, moody, etc.)
67
+ 3. Visual style (high-end, realistic, stylized, etc.)
68
+ 4. Rendering technique (Cinema 4D, Octane, etc.)
69
+
70
+ Also suggest a short, memorable name for this material style (1-2 words) based on the key visual characteristics.
71
+
72
+ Format the response as JSON with 'prompt' and 'suggestedName' fields.`;
73
+
74
+ console.log('Calling Gemini Vision API for image analysis...');
75
+
76
+ // Generate content
77
+ const result = await model.generateContent([prompt, ...imageParts]);
78
+ const response = result.response;
79
+ const responseText = response.text();
80
+
81
+ console.log('Received response from Gemini');
82
+
83
+ // Try to parse as JSON, fallback to text if needed
84
+ try {
85
+ // First try direct parse
86
+ const jsonResponse = JSON.parse(responseText);
87
+ return res.status(200).json(jsonResponse);
88
+ } catch (e) {
89
+ console.log('Response not in JSON format, attempting to extract JSON');
90
+
91
+ // Try to extract JSON from text response
92
+ try {
93
+ const jsonMatch = responseText.match(/\{[\s\S]*\}/);
94
+ if (jsonMatch) {
95
+ const extractedJson = JSON.parse(jsonMatch[0]);
96
+ return res.status(200).json(extractedJson);
97
+ }
98
+ } catch (extractError) {
99
+ console.error('Failed to extract JSON from response:', extractError);
100
+ }
101
+
102
+ // If all parsing attempts fail, create structured response from text
103
+ const lines = responseText.split('\n');
104
+ let suggestedName = 'Custom Material';
105
+
106
+ // Try to find a name in the response
107
+ for (const line of lines) {
108
+ if (line.toLowerCase().includes('name:') || line.toLowerCase().includes('suggested name:')) {
109
+ const namePart = line.split(':')[1];
110
+ if (namePart && namePart.trim()) {
111
+ suggestedName = namePart.trim();
112
+ // Remove quotes if present
113
+ suggestedName = suggestedName.replace(/^["'](.*)["']$/, '$1');
114
+ break;
115
+ }
116
+ }
117
+ }
118
+
119
+ return res.status(200).json({
120
+ prompt: responseText,
121
+ suggestedName
122
+ });
123
+ }
124
+ } catch (error) {
125
+ console.error('Error in analyze-image:', error);
126
+
127
+ // Check for quota exceeded errors
128
+ if (error.message?.includes('quota') || error.message?.includes('Resource has been exhausted')) {
129
+ return res.status(429).json({
130
+ error: 'API quota exceeded. Please try again later or use your own API key.'
131
+ });
132
+ }
133
+
134
+ return res.status(500).json({
135
+ error: 'Failed to analyze image',
136
+ details: error.message
137
+ });
138
+ }
139
+ }
pages/api/convert-to-doodle.js ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/generative-ai";
2
+ import { NextResponse } from 'next/server';
3
+
4
+ // Configuration for the API route
5
+ export const config = {
6
+ api: {
7
+ bodyParser: {
8
+ sizeLimit: '10mb', // Increase limit to 10MB (adjust if needed)
9
+ },
10
+ },
11
+ };
12
+
13
+ export default async function handler(req, res) {
14
+ // Only allow POST requests
15
+ if (req.method !== 'POST') {
16
+ return res.status(405).json({ error: 'Method not allowed' });
17
+ }
18
+
19
+ try {
20
+ const { imageData, customApiKey } = req.body;
21
+
22
+ // Validate inputs
23
+ if (!imageData) {
24
+ return res.status(400).json({ error: 'Image data is required' });
25
+ }
26
+
27
+ // Set up the API key
28
+ const apiKey = customApiKey || process.env.GEMINI_API_KEY;
29
+ if (!apiKey) {
30
+ return res.status(500).json({ error: 'API key is required' });
31
+ }
32
+
33
+ // Initialize the Gemini API
34
+ const genAI = new GoogleGenerativeAI(apiKey);
35
+ const model = genAI.getGenerativeModel({
36
+ model: "gemini-2.0-flash-exp-image-generation",
37
+ generationConfig: {
38
+ temperature: 1,
39
+ topP: 0.95,
40
+ topK: 40,
41
+ maxOutputTokens: 8192,
42
+ responseModalities: ["image", "text"]
43
+ }
44
+ });
45
+
46
+ // Create the prompt for doodle conversion
47
+ const prompt = `Could you please convert this image into a black and white doodle.
48
+ Requirements:
49
+ - Use ONLY pure black lines on a pure white background
50
+ - No gray tones or shading
51
+ - Maintain the key shapes and outlines
52
+ - Follow the original content but simplify if needed
53
+ - IMPORTANT: If this image contains any text, logo, or wordmark:
54
+ * Simply convert it to black and white, and pass it through
55
+ * Preserve ALL text exactly as it appears in the original
56
+ * Maintain the exact spelling, letterspacing, and arrangement of letters
57
+ * Keep text legible and clear with high contrast
58
+ * Do not simplify or omit any text elements
59
+ * Text should remain readable in the final doodle, and true to the original :))`;
60
+
61
+ // Prepare the generation content
62
+ const generationContent = [
63
+ {
64
+ inlineData: {
65
+ data: imageData,
66
+ mimeType: "image/png"
67
+ }
68
+ },
69
+ { text: prompt }
70
+ ];
71
+
72
+ // Generate content
73
+ const result = await model.generateContent(generationContent);
74
+ const response = await result.response;
75
+
76
+ // Process the response to extract image data
77
+ let convertedImageData = null;
78
+ for (const part of response.candidates[0].content.parts) {
79
+ if (part.inlineData) {
80
+ convertedImageData = part.inlineData.data;
81
+ break;
82
+ }
83
+ }
84
+
85
+ if (!convertedImageData) {
86
+ throw new Error('No image data received from the API');
87
+ }
88
+
89
+ // Return the converted image data
90
+ return res.status(200).json({
91
+ success: true,
92
+ imageData: convertedImageData
93
+ });
94
+ } catch (error) {
95
+ console.error("Error during doodle conversion:", error);
96
+ const errorMessage = error.message || "An unknown error occurred during conversion.";
97
+ // Ensure JSON response even on error
98
+ return res.status(500).json({ success: false, error: errorMessage });
99
+ }
100
+ }
pages/api/enhance-material.js ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+
3
+ export default async function handler(req, res) {
4
+ if (req.method !== 'POST') {
5
+ return res.status(405).json({ error: 'Method not allowed' });
6
+ }
7
+
8
+ try {
9
+ // Extract material description from request body
10
+ const { materialDescription } = req.body;
11
+
12
+ if (!materialDescription) {
13
+ return res.status(200).json({
14
+ name: 'Unknown Material',
15
+ details: 'Add standard material properties with accurate surface texturing.'
16
+ });
17
+ }
18
+
19
+ // Initialize the Google Generative AI with API key
20
+ const apiKey = process.env.GEMINI_API_KEY;
21
+ if (!apiKey) {
22
+ console.error("Missing GEMINI_API_KEY");
23
+ // Return a 200 with fallback data instead of error
24
+ return res.status(200).json({
25
+ name: materialDescription,
26
+ details: `Emphasize the characteristic properties of ${materialDescription.toLowerCase()} with accurate surface texturing.`
27
+ });
28
+ }
29
+
30
+ try {
31
+ const genAI = new GoogleGenerativeAI(apiKey);
32
+ const model = genAI.getGenerativeModel({
33
+ model: "gemini-pro",
34
+ generationConfig: {
35
+ temperature: 0.7,
36
+ topP: 0.8,
37
+ topK: 40
38
+ }
39
+ });
40
+
41
+ // Create prompt for material enhancement
42
+ const prompt = `Given the material description "${materialDescription}", provide:
43
+ 1. A concise material name (2-3 words maximum, keep original name if simple enough)
44
+ 2. Please provide contextual, specific material properties to enhance the existing prompt:
45
+ "Transform this sketch into a [material] material. Render it in a high-end 3D visualization style with professional studio lighting against a pure black background. Make it look like an elegant Cinema 4D and Octane rendering with detailed material properties and characteristics. The final result should be a premium product visualization with perfect studio lighting, crisp shadows, and high-end material definition."
46
+
47
+ Format response STRICTLY as JSON:
48
+ {
49
+ "name": "Material Name",
50
+ "details": "Only additional material properties,"
51
+ }
52
+
53
+ Requirements:
54
+ - Keep name simple if input is already concise (e.g., "rusted iron" stays as "Rusted Iron")
55
+ - Simplify complex descriptions (e.g., "glass beads made of fire" becomes "Molten Glass")
56
+ - Details should focus on physical properties, visual characteristics, and rendering techniques. Feel free to be creative! :)
57
+ - Do not repeat what's already in the base prompt (black background, lighting, etc)
58
+ - Keep details concise and technical`;
59
+
60
+ // Generate content with the model
61
+ const result = await model.generateContent(prompt);
62
+ const response = result.response;
63
+ const responseText = response.text();
64
+
65
+ try {
66
+ // Try to parse the response as JSON
67
+ const jsonResponse = JSON.parse(responseText);
68
+
69
+ // Validate the response format
70
+ if (!jsonResponse.name || !jsonResponse.details) {
71
+ throw new Error('Invalid response format from AI');
72
+ }
73
+
74
+ return res.status(200).json(jsonResponse);
75
+ } catch (error) {
76
+ console.error('Error parsing AI response or invalid format:', error.message);
77
+
78
+ // Fallback: Use the original description, capitalized, and provide empty details.
79
+ // This keeps the JSON structure consistent for the frontend.
80
+ const capitalizedName = materialDescription
81
+ .split(' ')
82
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
83
+ .join(' ');
84
+
85
+ return res.status(200).json({
86
+ name: capitalizedName,
87
+ details: ""
88
+ });
89
+ }
90
+ } catch (aiError) {
91
+ console.error('Error calling Generative AI Model:', aiError);
92
+ // Fallback if the AI call itself fails
93
+ const capitalizedName = materialDescription
94
+ .split(' ')
95
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
96
+ .join(' ');
97
+
98
+ return res.status(500).json({
99
+ name: capitalizedName,
100
+ details: ""
101
+ });
102
+ }
103
+ } catch (error) {
104
+ // Catch errors before AI initialization (e.g., API key issue)
105
+ console.error('API handler setup error:', error);
106
+ return res.status(500).json({ error: 'Internal server error' });
107
+ }
108
+ }
pages/api/enhance-prompt.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+
3
+ export default async function handler(req, res) {
4
+ if (req.method !== 'POST') {
5
+ return res.status(405).json({ error: 'Method not allowed' });
6
+ }
7
+
8
+ try {
9
+ const { materialName, basePrompt } = req.body;
10
+ // Use custom API key if provided, else use environment variable
11
+ // (Assuming customApiKey might be needed here too, like in other endpoints)
12
+ const apiKey = req.body.customApiKey || process.env.GEMINI_API_KEY;
13
+ const genAI = new GoogleGenerativeAI(apiKey);
14
+
15
+ // Use the specified flash model, configured for text-only output
16
+ const model = genAI.getGenerativeModel({
17
+ model: "gemini-2.0-flash-exp-image-generation", // Use the required model
18
+ generationConfig: {
19
+ // Configuration similar to generate.js's text-only mode
20
+ temperature: 0.8, // Adjust temperature for creativity if needed
21
+ topP: 0.95,
22
+ topK: 40,
23
+ maxOutputTokens: 8192, // Keep sufficient tokens for text
24
+ responseModalities: ["text"] // Explicitly request only text output
25
+ }
26
+ });
27
+
28
+ // --- Updated prompt with Simplified Classification (Material vs Style) ---
29
+ const enhancementPrompt = `CONTEXT: You are an expert AI assisting with 3D prompt generation. Your task is to analyze a user's input term, classify it as either MATERIAL or STYLE, and then generate an enhanced 3D rendering prompt for Cinema 4D and Octane based ONLY on that classification, rewriting the provided base prompt.
30
+
31
+ USER INPUT TERM: "${materialName}"
32
+ BASE PROMPT TO REWRITE: "${basePrompt}" // (Note: C4D/Octane, black background, studio lighting should generally be maintained unless STYLE dictates otherwise).
33
+
34
+ DEFINITIONS:
35
+ - MATERIAL: Describes the substance, texture, surface properties, or *essence* of a thing (e.g., 'chrome', 'bubbly glass', 'liquid honey', 'fur', 'horse', 'fish scales'). Applying this means rendering the sketch *as if made of* this substance or capturing its defining visual characteristics on the surface.
36
+ - STYLE: Describes the overall artistic aesthetic or rendering technique (e.g., 'Studio Ghibli', 'cyberpunk', 'art deco', 'sketch'). Applying this means rendering the sketch *in* this aesthetic.
37
+
38
+ TASK:
39
+ 1. **Classify** the USER INPUT TERM ("${materialName}") into ONE category: MATERIAL or STYLE based on the DEFINITIONS. If it doesn't clearly fit STYLE, classify it as MATERIAL.
40
+ 2. **Based ONLY on the category you chose**, follow the corresponding instructions below to rewrite the BASE PROMPT. Integrate the specific details smoothly.
41
+ 3. **Generate a simple, descriptive 'suggestedName'**:
42
+ * Capitalize the main words of the USER INPUT TERM ("${materialName}").
43
+ * Keep it short and representative (usually 1-3 words).
44
+ * If the input is complex (e.g., "plants made of copper beads"), simplify it concisely (e.g., "Copper Plants").
45
+ * Examples: "fish" -> "Fish", "liquid honey" -> "Liquid Honey", "studio ghibli" -> "Studio Ghibli", "plants made of copper beads" -> "Copper Plants".
46
+ 4. Output ONLY a single valid JSON object: {"enhancedPrompt": "...", "suggestedName": "..."}. Do not include your classification reasoning or any other text outside the JSON.
47
+
48
+ --- CONDITIONAL INSTRUCTIONS ---
49
+
50
+ ➡️ IF CLASSIFIED AS **MATERIAL**:
51
+ Rewrite the base prompt to apply the characteristics or substance of '${materialName}' TO the sketched shape. Emphasize unique physical properties (texture, reflectivity, translucency, pattern, finish, etc.) and how they interact with professional studio lighting against a black background. Ensure the Cinema 4D / Octane rendering highlights these specific visual characteristics.
52
+ **Use these examples for inspiration on detail and phrasing (DO NOT COPY VERBATIM):**
53
+ * *Chrome:* "Recreate this sketch as a physical chrome sculpture... highly reflective surfaces and sharp highlights characteristic of polished chrome."
54
+ * *Liquid Honey:* "Transform this sketch... rendered as if made entirely of thick, viscous, translucent liquid honey... highlight the honey's golden color, internal light scattering, and glossy surface sheen."
55
+ * *Soft Body:* "Convert this sketch into a soft body physics render made of a translucent, jelly-like material... highlight the material's subsurface scattering and wobbly surface properties."
56
+ * *(Implicit Horse/Fish Example):* For terms like 'horse' or 'fish', focus on applying their characteristic textures (e.g., 'short dense hair', 'overlapping scales') or essence to the existing shape, rather than modeling the animal itself.
57
+
58
+ ➡️ IF CLASSIFIED AS **STYLE**:
59
+ Rewrite the base prompt to render the sketch IN the style of '${materialName}'. Follow this structure:
60
+ 1. **Interpretation:** State that the core elements from the sketch should be faithfully interpreted.
61
+ 2. **Style Application:** Detail how the '${materialName}' aesthetic should be applied (linework, color palette, mood, rendering techniques). Adapt lighting/background if the style requires. Use C4D/Octane techniques adapted to the style.
62
+ 3. **Goal:** The final image should clearly look like the '${materialName}' style applied to the specific sketch.
63
+ **Use this 'Studio Ghibli' example for structure/depth inspiration (DO NOT COPY VERBATIM):**
64
+ * *Studio Ghibli Example Structure:* "First, faithfully interpret the core elements... Then, apply the following Studio Ghibli stylistic treatment: Aesthetic: Convert... Backgrounds: Use... Linework: Employ... Color: Utilize... Atmosphere: Infuse... Details: Add... Lighting: Implement... Goal: The final image must look like a high-quality Ghibli keyframe... Render using Cinema 4D and Octane..."
65
+
66
+ --- END OF CONDITIONAL INSTRUCTIONS ---
67
+
68
+ NOW, perform the classification for "${materialName}" (defaulting to MATERIAL if not clearly STYLE) and generate the required JSON output. Remember, ONLY the JSON.`;
69
+ // --- End of updated prompt ---
70
+
71
+ console.log(`Calling ${model.model} for simplified conditional prompt enhancement...`);
72
+ const result = await model.generateContent(enhancementPrompt);
73
+ const response = await result.response;
74
+ const responseText = response.text();
75
+ // --- Log the raw response ---
76
+ console.log("Raw AI response text:", responseText);
77
+ // ---
78
+
79
+ try {
80
+ let jsonResponse;
81
+ // --- Attempt to extract JSON more robustly ---
82
+ // 1. Look for JSON within ```json ... ``` markdown
83
+ const markdownMatch = responseText.match(/```json\s*([\s\S]*?)\s*```/);
84
+ if (markdownMatch && markdownMatch[1]) {
85
+ try {
86
+ jsonResponse = JSON.parse(markdownMatch[1]);
87
+ console.log("Parsed JSON from markdown block.");
88
+ } catch (e) {
89
+ console.warn("Failed to parse JSON from markdown block, trying direct parse.");
90
+ jsonResponse = JSON.parse(responseText); // Fallback to direct parse
91
+ }
92
+ } else {
93
+ // 2. If no markdown, try direct parse (might work if AI behaved)
94
+ jsonResponse = JSON.parse(responseText);
95
+ console.log("Parsed JSON directly.");
96
+ }
97
+ // --- End of robust extraction ---
98
+
99
+ // Basic validation
100
+ if (!jsonResponse || typeof jsonResponse.enhancedPrompt !== 'string' || typeof jsonResponse.suggestedName !== 'string') {
101
+ // Ensure fields exist and are strings
102
+ throw new Error("AI response missing required fields or fields are not strings.");
103
+ }
104
+ return res.status(200).json(jsonResponse);
105
+ } catch (e) {
106
+ console.error("Error parsing AI JSON response or invalid format:", e.message);
107
+ console.error("Original AI text that failed parsing:", responseText); // Log the text again on error
108
+ // Fallback if response isn't valid JSON after attempts
109
+ return res.status(200).json({
110
+ enhancedPrompt: basePrompt, // Use original base prompt
111
+ suggestedName: materialName // Use original material name
112
+ });
113
+ }
114
+ } catch (error) {
115
+ console.error('Error enhancing prompt:', error);
116
+ // Send 500 if anything in the main try block fails (API key, model call, etc.)
117
+ return res.status(500).json({ error: 'Failed to enhance prompt' });
118
+ }
119
+ }
pages/api/generate-thumbnail.js ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ // Define a fallback shape in case no reference image is provided and the file doesn't exist
6
+ // This is a simple white circle on black background encoded as base64
7
+ const DEFAULT_SHAPE_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAi8SURBVHgB7d1NbFTXGcfxM8aACYSXQBEhUEkDESRKNggCaLuosmFTqUKq1KibLqtWXbTqolI3lbqoumm7qFREUTabsEGUDYIEUBohIV4SCiYhvAXwCzaGsX3vHXvseAnFzMw9c+/5faQrKImdGVme//M8595zuoqJ+wdHu4HCeXP4xIHuYnvwvWpbcePOZGVyfrHaXW3/WfNs81/T/trE3NNq8/NnS9X5xWq1+blRrVZnZ+uNmalarcwVbUBpCQAQyNbRkV07Rrt7do9Dj43s3DzSvfFH/T9YnGyHg6kNYeDZ8+qzqbnF6pPpZ9WHM5OK/qPCnioA2vp7u/vunRz9eVdn8deNY6/1DvV393//W0nf44VnQ4+fxpWH03Ofv/Powb9ynxVA7gkAAO9Y/NMQMDK4peeTdw+N9fV095w+2v/j7aSuQfvswMPJ+cc377343/sPF+6/rDYaEvq7u7Zs7u3q3be9d9vY9t5Txfbj8eKjNSz+FdpnBf59Z/rxrXsvHk/UZh8UeY8AQK4JAECJvQr906eP9v80ica/Dq3F/+f9w73F4l+prL/2RuP5i/p/7j6bvXXvxfP3H8zfLeLtAyF/BABgiaqLf9GKbp9a+Vr8KxvB4FX6X24EhH/cfnrr3vS1IpxlEPJFAAAE2/iPTQ5vPzY5/JM8LfybQeD6nWc3/nn76YeFx3MKPxCEAAAU1HAx8B8vtv3jOV78l+vV4n/9zrMb//riyYcPns4p/EACAgBQIMM7+iYmhred2Lmt91TZFv9lWov/P28/vXr9i+mP7k8/r3llAGyQAADk3PB2i/+rbeQ/v1i4eu3zh9fuTD+2QwBsQOqCm1/9d9Vf5/d/eSJI/Z+E99rQli8fd5wYGep7N59/LRvx/b6enl2jO3qLAWBkzeMFAG9N9E0q+hsMFtv//GpyfOLY9h8s///tNQlbzS9+evRH+zaP9ZXO3m39Pcd/uv2rL4pd49A51l47ZnZ0d/ecLIavfaO7duwt3TGDDdp2/EfRNwEi7/wGgA2ov/yLnX9x9uKJ0UNjO/vK+BkA3tTQ4Jbjrw32DF/5+P7l63ef3LBDAHwHOwDAJnXWZv9zvHf47NGxbSND20qdYtvQluNvt7YYOAMABbPZNmXbAGjfEjh7YmTnyNiwswAQjY+XbHFz9sTIrrGdrWcEvHdAEiWOlgBQYLs29/bOPZ07e/eTs2/XGtvK/JkB1qvVcLz6bMCVk2NDuw/s6ttVfNl3DYI34xYAUHDDQ1uqz56dffbRrZPXJmfPPZtfGIrbDHyb1pD+7h29w5uH+x4c3jvQ0/w9ADaHHQBA4XV3dxfD/K4Pjh/adXR8yxVnAaDzWmcBTh/d3l88Y+Kq3xfQOU7KgBLZta1v4dF07XHz9r8N9ffMxG4GltOakV2D9VYbsPRZAGcCYGPcAgBKxmcGYP0sf0DJTOzcPrhnW+8pnwXg63xh7wRHQFBCPjPA6xrNz/Mbp1UAQF45CoLS/g1o/o2YmDs3c/aPl+biyuXYzUAMi3MP52Zmrz797WxjIXYrEEOluq3a1jMVuwkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGIQM6C8+nsrhwf3Ns9frP7n7vPYvUBH/PbXE5WxXQPVm5dqc7GbgRj6D8duAGJonajde7Sw78JfprfNLzaitwMd8Yd3x7tHdw80ZmYXe2M3AzE4CoLSmnuyMPLLc1+sPmw8HY7dC2zM785O1o/s22btR3nZAYDSmpubHTt2a2Ru9OL09fqzheJDUYnYEwXOLdTjtwJp2QGAEmucnLn3+MXi6Lmbs+dnGwuuoAAQjQAApfb48eNdr03V9nffmr9e/K/MAABEIwBAqbUWgPGHC/t6bs3ZBQAgGgEASmxpcZjY0Xt9bnrfhemp5ofjgyIAkDIBAErt1QJwYv+2fRcfzRybU8cAiEQAgFJ7tQCM7urfNzG/YAcAgGgEACi1pQXg2NGdw9cezwkAAEQjAECpLS0AE/u3VYp11g4AANEIAFBqSwvA8V2D3b0VawcAwPswC4DSWloAuvs9JAYAYwcASu3VAjBjBwCA6KwAUGqvFoApzwEAICoBAAZEAACIxwoApSYAABCPAAClJgAAEI8VAKUmAAAQjwAApSYAABCPFQBKTQAAIB4BAEpNAAAgHisAlJoAAEA8AgCUmgAAQDxWACg1AQCAeAQAKDUBAIB4rABQagIAAPEIAFBqAgAA8VgBoNQEAADiEQCg1AQAAOKxAkCpCQAAxCMAQKkJAADEYwWAUhMAAIhHAIBSEwAAiMcKAKUmAAAQjwAApSYAABCPFQBKTQAAIB4BAEpNAAAgHisAlJoAAEA8AgCUmgAAQDxWACg1AQCAeAQAKDUBAIB4rABQagIAAPEIAFBqAgAA8VgBoNQEAADiEQCg1AQAAOKxAkCpCQAAxCMAQKkJAADEYwWAUhMAAIhHAIBSEwAAiMcKAKUmAAAQjwAApSYAABCPFQBKTQAAIB4BAEpNAAAgHisAlJoAAEA8AgCUmgAAQDxWACg1AQCAeAQAKDUBAIB4rABQagIAAPEIAFBqAgAA8VgBoNQEAADiEQCg1AQAAOKxAkCpCQAAxCMAQKkJAADEYwWAUhMAAIhHAIBSEwAAiMcKAFDSI2AeRxWdAABATsRuANKrx24AYnj1CbAZAq7G7gUAotpx/EfRNwEi7/wGgBIrtn//OLa/a+rWyZGZ4uBNBfVAJC9nYncAnfOd5/evv9j5z56bHKkVC2zRQdEJVHwIAMqo9eDvv04V535zbGLyq8qB4uMuhTWxFw9I9S/tLx4VfXFWqrHe1fU/bEAO49+azcIAAAAASUVORK5CYII=';
8
+
9
+ // Helper function to read the image file
10
+ const loadImageData = (filePath) => {
11
+ try {
12
+ const absolutePath = path.resolve('./public', filePath); // Adjust if your public dir is elsewhere
13
+ console.log(`Loading reference image from: ${absolutePath}`);
14
+ if (fs.existsSync(absolutePath)) {
15
+ const imageBuffer = fs.readFileSync(absolutePath);
16
+ return imageBuffer.toString('base64');
17
+ } else {
18
+ console.error(`Reference image not found at: ${absolutePath}`);
19
+ return null;
20
+ }
21
+ } catch (error) {
22
+ console.error('Error loading reference image:', error);
23
+ return null;
24
+ }
25
+ };
26
+
27
+ export default async function handler(req, res) {
28
+ if (req.method !== 'POST') {
29
+ return res.status(405).json({ error: 'Method not allowed' });
30
+ }
31
+
32
+ console.log('Starting thumbnail generation request');
33
+
34
+ try {
35
+ const { prompt, referenceImageData } = req.body;
36
+
37
+ if (!prompt) {
38
+ console.error('Missing prompt in request');
39
+ return res.status(400).json({ error: 'Prompt is required' });
40
+ }
41
+
42
+ console.log('Received prompt:', prompt.substring(0, 100));
43
+
44
+ // Initialize the model
45
+ const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
46
+ const model = genAI.getGenerativeModel({
47
+ model: "gemini-2.0-flash-exp-image-generation",
48
+ generationConfig: {
49
+ temperature: 0.8,
50
+ topP: 0.9,
51
+ topK: 40,
52
+ maxOutputTokens: 8192,
53
+ }
54
+ });
55
+
56
+ // Get the base image data (either from request or default)
57
+ const baseImageData = referenceImageData || DEFAULT_SHAPE_DATA_URL;
58
+ console.log('Using reference image:', referenceImageData ? 'Custom' : 'Default');
59
+
60
+ // Construct the prompt for the image model
61
+ const imageGenerationPrompt = `Treat the object shown in the reference image (the white circle) as a simple 3D sphere. Apply the following material description or style directly onto this sphere...`;
62
+
63
+ console.log('Generated image prompt:', imageGenerationPrompt.substring(0, 100));
64
+
65
+ // Prepare generation content
66
+ const generationContent = [
67
+ { text: imageGenerationPrompt },
68
+ {
69
+ inlineData: {
70
+ data: baseImageData,
71
+ mimeType: "image/png"
72
+ }
73
+ },
74
+ ];
75
+
76
+ console.log('Calling Gemini API for image generation...');
77
+
78
+ // Generate content
79
+ const result = await model.generateContent(generationContent);
80
+ const response = await result.response;
81
+
82
+ console.log('Received response from Gemini API');
83
+
84
+ // Process response
85
+ let generatedImageData = null;
86
+ // Safely access parts
87
+ const parts = response?.candidates?.[0]?.content?.parts;
88
+
89
+ if (!parts) {
90
+ console.error('No parts in response:', JSON.stringify(response));
91
+ throw new Error('No parts in response from Gemini API');
92
+ }
93
+
94
+ console.log('Processing response parts:', parts.length);
95
+
96
+ if (Array.isArray(parts)) {
97
+ for (const part of parts) {
98
+ if (part.inlineData && part.inlineData.mimeType?.startsWith('image/')) {
99
+ generatedImageData = part.inlineData.data;
100
+ console.log('Found image data in response');
101
+ break;
102
+ }
103
+ }
104
+ }
105
+
106
+ if (!generatedImageData) {
107
+ console.error('No image data in response parts:', JSON.stringify(parts));
108
+ throw new Error('No image data found in response parts');
109
+ }
110
+
111
+ console.log('Successfully generated thumbnail');
112
+ return res.status(200).json({
113
+ success: true,
114
+ imageData: generatedImageData
115
+ });
116
+
117
+ } catch (error) {
118
+ console.error('Error in thumbnail generation:', error);
119
+ return res.status(500).json({
120
+ success: false,
121
+ error: error.message || 'An error occurred during thumbnail generation'
122
+ });
123
+ }
124
+ }
pages/api/generate.js ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI } from "@google/generative-ai";
2
+
3
+ // Configure API route options
4
+ export const config = {
5
+ api: {
6
+ bodyParser: {
7
+ sizeLimit: '10mb' // Increase the body size limit to 10MB
8
+ }
9
+ }
10
+ };
11
+
12
+ export default async function handler(req, res) {
13
+ // Only allow POST requests
14
+ if (req.method !== 'POST') {
15
+ return res.status(405).json({ error: 'Method not allowed' });
16
+ }
17
+
18
+ // Add retry configuration
19
+ const MAX_RETRIES = 2;
20
+ const RETRY_DELAY = 1000; // 1 second
21
+ let retryCount = 0;
22
+
23
+ // Function to wait for a delay
24
+ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
25
+
26
+ try {
27
+ const { prompt, drawingData, customApiKey, generateTextOnly } = req.body;
28
+
29
+ // Validate prompt
30
+ if (!prompt) {
31
+ console.error('Missing prompt in request');
32
+ return res.status(400).json({ error: 'Prompt is required' });
33
+ }
34
+
35
+ // Log the API request details (excluding the full drawing data for brevity)
36
+ console.log('API Request:', {
37
+ prompt: prompt.substring(0, 100) + '...',
38
+ hasDrawingData: !!drawingData,
39
+ hasCustomApiKey: !!customApiKey,
40
+ generateTextOnly: !!generateTextOnly,
41
+ retryCount
42
+ });
43
+
44
+ // Check API key
45
+ const apiKey = customApiKey || process.env.GEMINI_API_KEY;
46
+ if (!apiKey) {
47
+ console.error('Missing Gemini API key');
48
+ return res.status(500).json({ error: 'API key is not configured' });
49
+ }
50
+
51
+ // Retry loop for handling transient errors
52
+ while (true) {
53
+ try {
54
+ console.log(`Initializing Gemini AI with API key (attempt ${retryCount + 1}/${MAX_RETRIES + 1})`);
55
+ const genAI = new GoogleGenerativeAI(apiKey);
56
+
57
+ // Configure the model with settings based on the request type
58
+ console.log('Configuring Gemini model');
59
+ const model = genAI.getGenerativeModel({
60
+ model: "gemini-2.0-flash-exp-image-generation",
61
+ generationConfig: {
62
+ temperature: 1,
63
+ topP: 0.95,
64
+ topK: 40,
65
+ maxOutputTokens: 8192,
66
+ responseModalities: generateTextOnly ? ["text"] : ["image", "text"]
67
+ }
68
+ });
69
+
70
+ // Handle text-only generation
71
+ if (generateTextOnly) {
72
+ console.log('Generating text-only response');
73
+
74
+ // Make text-only API call
75
+ const result = await model.generateContent(prompt);
76
+ const response = await result.response;
77
+
78
+ // Extract text from response
79
+ const textResponse = response.text();
80
+
81
+ console.log('Generated text response:', textResponse.substring(0, 100) + '...');
82
+
83
+ return res.status(200).json({
84
+ success: true,
85
+ textResponse: textResponse
86
+ });
87
+ }
88
+
89
+ // For image generation, proceed as normal
90
+ // Prepare the generation content
91
+ let generationContent;
92
+ if (drawingData) {
93
+ console.log('Including drawing data in generation request');
94
+ // If we have drawing data, include it in the request
95
+ generationContent = [
96
+ {
97
+ inlineData: {
98
+ data: drawingData,
99
+ mimeType: "image/png"
100
+ }
101
+ },
102
+ { text: prompt }
103
+ ];
104
+ } else {
105
+ console.log('Using text-only prompt for generation');
106
+ // Text-only prompt if no drawing
107
+ generationContent = [{ text: prompt }];
108
+ }
109
+
110
+ console.log(`Calling Gemini API for image generation (attempt ${retryCount + 1}/${MAX_RETRIES + 1})...`);
111
+ const result = await model.generateContent(generationContent);
112
+ console.log('Gemini API response received');
113
+
114
+ const response = await result.response;
115
+
116
+ // Process the response to extract image data
117
+ let imageData = null;
118
+ if (!response?.candidates?.[0]?.content?.parts) {
119
+ console.error('Invalid response structure:', response);
120
+ throw new Error('Invalid response structure from Gemini API');
121
+ }
122
+
123
+ for (const part of response.candidates[0].content.parts) {
124
+ if (part.inlineData) {
125
+ imageData = part.inlineData.data;
126
+ console.log('Found image data in response');
127
+ break;
128
+ }
129
+ }
130
+
131
+ if (!imageData) {
132
+ console.error('No image data in response parts:', response.candidates[0].content.parts);
133
+ throw new Error('No image data found in response parts');
134
+ }
135
+
136
+ console.log('Successfully generated image');
137
+ return res.status(200).json({
138
+ success: true,
139
+ imageData: imageData
140
+ });
141
+
142
+ // If we reach here, we succeeded, so break out of the retry loop
143
+ break;
144
+
145
+ } catch (attemptError) {
146
+ // Only retry certain types of errors that might be transient
147
+ const isRetryableError =
148
+ attemptError.message?.includes('timeout') ||
149
+ attemptError.message?.includes('network') ||
150
+ attemptError.message?.includes('ECONNRESET') ||
151
+ attemptError.message?.includes('rate limit') ||
152
+ attemptError.message?.includes('503') ||
153
+ attemptError.message?.includes('500') ||
154
+ attemptError.message?.includes('connection');
155
+
156
+ // Check if we should retry
157
+ if (retryCount < MAX_RETRIES && isRetryableError) {
158
+ console.log(`Retryable error encountered (${retryCount + 1}/${MAX_RETRIES}):`, attemptError.message);
159
+ retryCount++;
160
+ // Wait before retrying
161
+ await wait(RETRY_DELAY * retryCount);
162
+ continue;
163
+ }
164
+
165
+ // If we've exhausted retries or it's not a retryable error, rethrow
166
+ throw attemptError;
167
+ }
168
+ }
169
+ } catch (error) {
170
+ console.error('Error in /api/generate:', error);
171
+
172
+ // Check for specific error types
173
+ if (error.message?.includes('quota') || error.message?.includes('Resource has been exhausted')) {
174
+ return res.status(429).json({
175
+ error: 'API quota exceeded. Please try again later or use your own API key.'
176
+ });
177
+ }
178
+
179
+ if (error.message?.includes('API key')) {
180
+ return res.status(500).json({
181
+ error: 'Invalid or missing API key. Please check your configuration.'
182
+ });
183
+ }
184
+
185
+ if (error.message?.includes('safety') || error.message?.includes('blocked') || error.message?.includes('harmful')) {
186
+ return res.status(400).json({
187
+ error: 'Content was blocked due to safety filters. Try a different prompt.'
188
+ });
189
+ }
190
+
191
+ return res.status(500).json({
192
+ error: error.message || 'An error occurred during generation.',
193
+ details: error.stack,
194
+ retries: retryCount
195
+ });
196
+ }
197
+ }
pages/api/hello.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2
+
3
+ export default function handler(req, res) {
4
+ res.status(200).json({ name: "John Doe" });
5
+ }
pages/api/refine.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI } from "@google/generative-ai";
2
+
3
+ // Configure API route options
4
+ export const config = {
5
+ api: {
6
+ bodyParser: {
7
+ sizeLimit: '10mb' // Increase the body size limit to 10MB
8
+ }
9
+ }
10
+ };
11
+
12
+ export default async function handler(req, res) {
13
+ if (req.method !== 'POST') {
14
+ return res.status(405).json({ error: 'Method not allowed' });
15
+ }
16
+
17
+ try {
18
+ const { prompt, imageData, customApiKey } = req.body;
19
+
20
+ // Use custom API key if provided
21
+ const apiKey = customApiKey || process.env.GEMINI_API_KEY;
22
+ const genAI = new GoogleGenerativeAI(apiKey);
23
+
24
+ // Configure the model with the correct settings
25
+ const model = genAI.getGenerativeModel({
26
+ model: "gemini-2.0-flash-exp-image-generation",
27
+ generationConfig: {
28
+ temperature: 1,
29
+ topP: 0.95,
30
+ topK: 40,
31
+ maxOutputTokens: 8192,
32
+ responseModalities: ["image", "text"]
33
+ }
34
+ });
35
+
36
+ // Prepare a more specific prompt for the refinement
37
+ const refinementPrompt = `Please refine this image according to the following instruction: "${prompt}".
38
+ Maintain the artistic quality and style while applying the requested changes.
39
+ Ensure the output is a high-quality image that preserves the original intent while incorporating the refinement.`;
40
+
41
+ // Prepare the generation content with both image and prompt
42
+ const generationContent = [
43
+ {
44
+ inlineData: {
45
+ data: imageData,
46
+ mimeType: "image/png"
47
+ }
48
+ },
49
+ { text: refinementPrompt }
50
+ ];
51
+
52
+ // Generate content with the image and prompt
53
+ const result = await model.generateContent(generationContent);
54
+ const response = await result.response;
55
+
56
+ // Process the response to extract image data
57
+ let refinedImageData = null;
58
+ for (const part of response.candidates[0].content.parts) {
59
+ if (part.inlineData) {
60
+ refinedImageData = part.inlineData.data;
61
+ break;
62
+ }
63
+ }
64
+
65
+ if (!refinedImageData) {
66
+ throw new Error('No image data received from the API');
67
+ }
68
+
69
+ // Return the refined image data
70
+ return res.status(200).json({
71
+ success: true,
72
+ imageData: refinedImageData
73
+ });
74
+ } catch (error) {
75
+ console.error('Error in /api/refine:', error);
76
+
77
+ // Check for specific error types
78
+ if (error.message?.includes('quota') || error.message?.includes('Resource has been exhausted')) {
79
+ return res.status(429).json({
80
+ error: 'API quota exceeded. Please try again later or use your own API key.'
81
+ });
82
+ }
83
+
84
+ return res.status(500).json({
85
+ error: 'An error occurred during image refinement.'
86
+ });
87
+ }
88
+ }
pages/api/validate-key.js ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI } from "@google/generative-ai";
2
+
3
+ export default async function handler(req, res) {
4
+ // Only allow POST requests
5
+ if (req.method !== 'POST') {
6
+ return res.status(405).json({ error: 'Method not allowed' });
7
+ }
8
+
9
+ // Extract API key from request
10
+ const { apiKey } = req.body;
11
+
12
+ if (!apiKey) {
13
+ return res.status(400).json({
14
+ valid: false,
15
+ error: 'No API key provided'
16
+ });
17
+ }
18
+
19
+ try {
20
+ // Initialize the Gemini API
21
+ const genAI = new GoogleGenerativeAI(apiKey);
22
+
23
+ // Use a simple model call with minimal tokens to check key validity
24
+ const model = genAI.getGenerativeModel({
25
+ model: "gemini-1.5-flash",
26
+ generationConfig: {
27
+ temperature: 0,
28
+ maxOutputTokens: 10
29
+ }
30
+ });
31
+
32
+ // Make a simple API call to test the key
33
+ const prompt = "Respond with 'valid' and nothing else.";
34
+ const result = await model.generateContent(prompt);
35
+ const response = await result.response;
36
+
37
+ // If we get here, the key is valid
38
+ return res.status(200).json({
39
+ valid: true
40
+ });
41
+ } catch (error) {
42
+ console.error('API key validation error:', error.message);
43
+
44
+ // Check if the error is due to an invalid API key
45
+ if (
46
+ error.message?.includes('invalid API key') ||
47
+ error.message?.includes('API key not valid') ||
48
+ error.message?.includes('403')
49
+ ) {
50
+ return res.status(200).json({
51
+ valid: false,
52
+ error: 'Invalid API key'
53
+ });
54
+ }
55
+
56
+ // For other errors (like network issues), consider the key potentially valid
57
+ // to avoid disrupting users unnecessarily
58
+ return res.status(200).json({
59
+ valid: true,
60
+ warning: 'Could not fully validate key due to error: ' + error.message
61
+ });
62
+ }
63
+ }
pages/api/visual-enhance-prompt.js ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+
3
+ // Configure API route options
4
+ export const config = {
5
+ api: {
6
+ bodyParser: {
7
+ sizeLimit: '10mb' // Increase the body size limit to 10MB for larger images
8
+ }
9
+ }
10
+ };
11
+
12
+ export default async function handler(req, res) {
13
+ if (req.method !== 'POST') {
14
+ return res.status(405).json({ error: 'Method not allowed' });
15
+ }
16
+
17
+ try {
18
+ const { image, basePrompt } = req.body;
19
+ if (!image) {
20
+ return res.status(400).json({ error: 'Image is required' });
21
+ }
22
+
23
+ // Extract base64 data
24
+ const base64Data = image.split(',')[1];
25
+ if (!base64Data) {
26
+ return res.status(400).json({ error: 'Invalid image format' });
27
+ }
28
+
29
+ // Use custom API key if provided, else use environment variable
30
+ const apiKey = req.body.customApiKey || process.env.GEMINI_API_KEY;
31
+ const genAI = new GoogleGenerativeAI(apiKey);
32
+
33
+ // Use the image-capable model with text output configuration
34
+ const model = genAI.getGenerativeModel({
35
+ model: "gemini-2.0-flash-exp-image-generation", // Use the required model with image capabilities
36
+ generationConfig: {
37
+ temperature: 0.8,
38
+ topP: 0.95,
39
+ topK: 40,
40
+ maxOutputTokens: 8192,
41
+ responseModalities: ["text"] // Explicitly request only text output
42
+ }
43
+ });
44
+
45
+ // Prepare the image parts
46
+ const imageParts = [
47
+ {
48
+ inlineData: {
49
+ data: base64Data,
50
+ mimeType: "image/jpeg"
51
+ }
52
+ }
53
+ ];
54
+
55
+ // Create the prompt for analyzing the image and generating material details
56
+ const defaultBasePrompt = "Transform this sketch into a material with professional studio lighting against a pure black background. Render it in Cinema 4D with Octane for a high-end 3D visualization.";
57
+ const enhancementPrompt = `CONTEXT: You are an expert AI assisting with 3D prompt generation. Your task is to analyze the uploaded reference image, extract its key visual qualities, and generate an enhanced 3D rendering prompt for Cinema 4D and Octane based on these visual qualities.
58
+
59
+ BASE PROMPT TO REWRITE: "${basePrompt || defaultBasePrompt}" // (Note: C4D/Octane, black background, studio lighting should generally be maintained unless the image style dictates otherwise).
60
+
61
+ TASK:
62
+ 1. **Analyze the image** to identify its dominant visual characteristics:
63
+ * Material properties
64
+ * Texture patterns and surface details
65
+ * Color schemes and visual style
66
+ * Lighting and mood
67
+ * Any special effects or unique visual elements
68
+
69
+ 2. **Generate an enhanced prompt** that:
70
+ * Accurately captures the essence of the reference image's material/style
71
+ * Provides clear direction for a 3D artist to recreate similar visual qualities
72
+ * Includes specific details about textures, lighting, rendering techniques
73
+ * Maintains the Cinema 4D/Octane rendering requirements with studio lighting and black background (unless style requires otherwise)
74
+
75
+ 3. **Generate a simple, descriptive 'suggestedName'**:
76
+ * Create a concise, memorable name (1-3 words) that captures the essence of the material/style
77
+ * Capitalize main words
78
+ * Examples: "Liquid Gold", "Frosted Glass", "Neon Wire", "Velvet Fabric"
79
+
80
+ 4. Output ONLY a single valid JSON object: {"enhancedPrompt": "...", "suggestedName": "..."}. Do not include your analysis reasoning or any other text outside the JSON.
81
+
82
+ EXAMPLES OF GOOD RESPONSES:
83
+ * For a chrome metal image: {"enhancedPrompt": "Recreate this sketch as a physical chrome sculpture with highly reflective surfaces that catch and distort reflections. Render it in Cinema 4D with Octane, using professional studio lighting against a pure black background to highlight the mirror-like finish and metallic sheen.", "suggestedName": "Chrome Metal"}
84
+ * For a honey-like substance: {"enhancedPrompt": "Transform this sketch into a form made of translucent amber honey with thick, viscous properties. Capture the way light penetrates and scatters within the golden material, creating rich internal glow. Render in Cinema 4D with Octane against a black background with dramatic studio lighting to emphasize the material's refraction, high-gloss surface, and organic flowing behavior.", "suggestedName": "Liquid Honey"}`;
85
+
86
+ console.log('Calling Gemini Vision API for image analysis and prompt enhancement...');
87
+
88
+ // Generate content by sending both the text prompt and image
89
+ const result = await model.generateContent([enhancementPrompt, ...imageParts]);
90
+ const response = await result.response;
91
+ const responseText = response.text();
92
+
93
+ console.log('Received response from Gemini');
94
+ console.log("Raw AI response text:", responseText);
95
+
96
+ try {
97
+ let jsonResponse;
98
+ // --- Attempt to extract JSON more robustly ---
99
+ // 1. Look for JSON within ```json ... ``` markdown
100
+ const markdownMatch = responseText.match(/```json\s*([\s\S]*?)\s*```/);
101
+ if (markdownMatch && markdownMatch[1]) {
102
+ try {
103
+ jsonResponse = JSON.parse(markdownMatch[1]);
104
+ console.log("Parsed JSON from markdown block.");
105
+ } catch (e) {
106
+ console.warn("Failed to parse JSON from markdown block, trying direct parse.");
107
+ jsonResponse = JSON.parse(responseText); // Fallback to direct parse
108
+ }
109
+ } else {
110
+ // 2. If no markdown, try direct parse (might work if AI behaved)
111
+ try {
112
+ jsonResponse = JSON.parse(responseText);
113
+ console.log("Parsed JSON directly.");
114
+ } catch (e) {
115
+ console.log('Response not in JSON format, attempting to extract JSON');
116
+
117
+ // Try to extract JSON from text response
118
+ const jsonMatch = responseText.match(/\{[\s\S]*\}/);
119
+ if (jsonMatch) {
120
+ jsonResponse = JSON.parse(jsonMatch[0]);
121
+ console.log("Extracted JSON using regex.");
122
+ } else {
123
+ throw new Error("Could not extract JSON from response");
124
+ }
125
+ }
126
+ }
127
+ // --- End of robust extraction ---
128
+
129
+ // Basic validation
130
+ if (!jsonResponse || typeof jsonResponse.enhancedPrompt !== 'string' || typeof jsonResponse.suggestedName !== 'string') {
131
+ // Ensure fields exist and are strings
132
+ throw new Error("AI response missing required fields or fields are not strings.");
133
+ }
134
+
135
+ // Now let's generate a thumbnail using the enhanced prompt
136
+ console.log("Generating thumbnail with enhanced prompt");
137
+
138
+ // Variable to store the thumbnail image data
139
+ let thumbnailImageData = null;
140
+
141
+ try {
142
+ // Create a new model instance configured for image generation
143
+ const thumbnailModel = genAI.getGenerativeModel({
144
+ model: "gemini-2.0-flash-exp-image-generation",
145
+ generationConfig: {
146
+ temperature: 0.8,
147
+ topP: 0.9,
148
+ topK: 40,
149
+ maxOutputTokens: 8192,
150
+ responseModalities: ["image", "text"] // Include both image and text modalities
151
+ }
152
+ });
153
+
154
+ // Create a thumbnail generation prompt that applies the material to a sphere
155
+ const thumbnailPrompt = `Using this uploaded reference image only as visual inspiration for material properties, create a clean isolated 3D sphere with the following material applied to it:
156
+
157
+ Material Description: "${jsonResponse.enhancedPrompt}"
158
+
159
+ Requirements:
160
+ - Create a simple 3D sphere (like a white ball) against a pure black background
161
+ - Apply only the material described above to the sphere
162
+ - Use professional studio lighting to showcase the material properties
163
+ - The sphere should take up most of the frame
164
+ - The final result should be a clean, professional material preview
165
+ - Do NOT maintain the original shape or content of the reference image
166
+ - Focus ONLY on accurately representing the material properties on a sphere`;
167
+
168
+ // Prepare the generation content - order is important!
169
+ const generationContent = [
170
+ {
171
+ inlineData: {
172
+ data: base64Data,
173
+ mimeType: "image/jpeg"
174
+ }
175
+ },
176
+ { text: thumbnailPrompt }
177
+ ];
178
+
179
+ // Generate content
180
+ const thumbnailResult = await thumbnailModel.generateContent(generationContent);
181
+ const thumbnailResponse = await thumbnailResult.response;
182
+
183
+ // Extract the image data from the response
184
+ for (const part of thumbnailResponse.candidates[0].content.parts) {
185
+ if (part.inlineData) {
186
+ thumbnailImageData = part.inlineData.data;
187
+ break;
188
+ }
189
+ }
190
+
191
+ // Check if we got image data back
192
+ if (!thumbnailImageData) {
193
+ console.error('No image data received from the Gemini API for thumbnail generation');
194
+ // Continue without thumbnail but don't fail the whole request
195
+ }
196
+ } catch (thumbnailError) {
197
+ console.error('Error generating thumbnail:', thumbnailError);
198
+ // Continue without failing the whole request
199
+ // thumbnailImageData remains null
200
+ }
201
+
202
+ // Return the complete response with both text and image data
203
+ return res.status(200).json({
204
+ enhancedPrompt: jsonResponse.enhancedPrompt,
205
+ suggestedName: jsonResponse.suggestedName,
206
+ imageData: thumbnailImageData
207
+ });
208
+
209
+ } catch (e) {
210
+ console.error("Error processing AI response:", e.message);
211
+ console.error("Original AI text that failed processing:", responseText);
212
+
213
+ // Create a fallback response
214
+ const fallbackName = "Custom Material";
215
+ const fallbackPrompt = basePrompt || defaultBasePrompt;
216
+
217
+ return res.status(200).json({
218
+ enhancedPrompt: fallbackPrompt,
219
+ suggestedName: fallbackName,
220
+ imageData: null // No thumbnail in fallback case
221
+ });
222
+ }
223
+ } catch (error) {
224
+ console.error('Error in visual-enhance-prompt:', error);
225
+
226
+ // Check for quota exceeded errors
227
+ if (error.message?.includes('quota') || error.message?.includes('Resource has been exhausted')) {
228
+ return res.status(429).json({
229
+ error: 'API quota exceeded. Please try again later or use your own API key.'
230
+ });
231
+ }
232
+
233
+ return res.status(500).json({
234
+ error: 'Failed to process image',
235
+ details: error.message
236
+ });
237
+ }
238
+ }
pages/index.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Head from "next/head";
2
+ import dynamic from 'next/dynamic';
3
+
4
+ // Dynamically import the CanvasContainer component with SSR disabled
5
+ const CanvasContainer = dynamic(() => import('../components/CanvasContainer'), { ssr: false });
6
+
7
+ export default function HomePage() {
8
+ return (
9
+ <>
10
+ <Head>
11
+ <title>Chrome Sketch Generator</title>
12
+ <meta name="description" content="Turn your sketches into chrome sculptures" />
13
+ <link rel="icon" href="/favicon.ico" />
14
+ </Head>
15
+ <CanvasContainer />
16
+ </>
17
+ );
18
+ }
public/GoogleSans/GoogleSans-Medium.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0994842b75ad61e6e1fcc0c7a47b7dcce351be36b4f2456a6684648478b08692
3
+ size 178132
public/GoogleSans/GoogleSans-MediumItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b03977bc652e580acfe0edc7eadbf5004a6c7fd3e67356af48e1fe4a241661b7
3
+ size 183516
public/GoogleSans/GoogleSans-Regular.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:21af9156c5e5d661640cea25d851ceb3d866185ee038f5a1c8866c8ba4294e62
3
+ size 178668
public/GoogleSans/GoogleSansDisplay-Bold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1e8b2e170cd91d732a24dfb69bfac10d410b0d765f12628ed9a70266c5da7830
3
+ size 175860
public/GoogleSans/GoogleSansDisplay-BoldItalic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:314d2f951e1bd1a0368b8f8d45589049e750cc3c10f2d366c0eb6d5fbc5697bc
3
+ size 184800
public/GoogleSans/GoogleSansDisplay-Italic.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7ccb361856d661fb9a1a5fffbdf7441ae24c670b21aab5619c6f2a01d4e80c80
3
+ size 184596
public/GoogleSans/GoogleSansDisplay-Regular.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0a4c51691a0d484d5e2422554907122d267c8cfd65946994e5e3e26d80068cdc
3
+ size 176520
public/favicon.ico ADDED

Git LFS Details

  • SHA256: dab9b2b63e5f93f567c91419373b84f8e74227f9cc89d3719e50377ba2dcf1eb
  • Pointer size: 130 Bytes
  • Size of remote file: 17.1 kB