Spaces:
Running
Running
Commit
·
5f07a23
0
Parent(s):
Initial commit with proper LFS tracking
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +36 -0
- .gitignore +37 -0
- Dockerfile +61 -0
- README.md +36 -0
- components/ActionBar.js +61 -0
- components/AddMaterialModal.js +484 -0
- components/ApiKeyModal.js +66 -0
- components/BottomToolBar.js +25 -0
- components/Canvas.js +1099 -0
- components/CanvasContainer.js +1651 -0
- components/DimensionSelector.js +130 -0
- components/DisplayCanvas.js +325 -0
- components/ErrorModal.js +100 -0
- components/Header.js +26 -0
- components/HeaderButtons.js +47 -0
- components/HistoryModal.js +113 -0
- components/ImageRefiner.js +41 -0
- components/LibraryPage.js +233 -0
- components/MaterialLibrary.tsx +65 -0
- components/OutputOptionsBar.js +67 -0
- components/StyleSelector.js +1571 -0
- components/TextInput.js +38 -0
- components/ToolBar.js +96 -0
- components/utils/canvasUtils.js +230 -0
- docker-compose.yml +18 -0
- jsconfig.json +7 -0
- next.config.js +36 -0
- package-lock.json +1626 -0
- package.json +31 -0
- pages/_app.js +5 -0
- pages/_document.js +13 -0
- pages/api/analyze-image.js +139 -0
- pages/api/convert-to-doodle.js +100 -0
- pages/api/enhance-material.js +108 -0
- pages/api/enhance-prompt.js +119 -0
- pages/api/generate-thumbnail.js +124 -0
- pages/api/generate.js +197 -0
- pages/api/hello.js +5 -0
- pages/api/refine.js +88 -0
- pages/api/validate-key.js +63 -0
- pages/api/visual-enhance-prompt.js +238 -0
- pages/index.js +18 -0
- public/GoogleSans/GoogleSans-Medium.ttf +3 -0
- public/GoogleSans/GoogleSans-MediumItalic.ttf +3 -0
- public/GoogleSans/GoogleSans-Regular.ttf +3 -0
- public/GoogleSans/GoogleSansDisplay-Bold.ttf +3 -0
- public/GoogleSans/GoogleSansDisplay-BoldItalic.ttf +3 -0
- public/GoogleSans/GoogleSansDisplay-Italic.ttf +3 -0
- public/GoogleSans/GoogleSansDisplay-Regular.ttf +3 -0
- 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
|