Compare commits
32
Commits
c11e6b52cc
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb2f844e5 | ||
|
|
66206c7ce5 | ||
|
|
e8b88fcead | ||
|
|
cf3c114172 | ||
|
|
f898dfadb4 | ||
|
|
49a3882441 | ||
|
|
86ba3a961c | ||
|
|
0623f30837 | ||
|
|
aa5de9f197 | ||
|
|
585e6a2032 | ||
|
|
df40822129 | ||
|
|
76d4d39687 | ||
|
|
2d51352854 | ||
|
|
c4253022db | ||
|
|
6e182cf4ac | ||
|
|
12e406664b | ||
|
|
e2ae339abf | ||
|
|
c7ebd76e1c | ||
|
|
b7a08b687d | ||
|
|
cbf44587aa | ||
|
|
603aee646c | ||
|
|
3351ab43e1 | ||
|
|
23941c23ed | ||
|
|
1e79ca762f | ||
|
|
f893ed697b | ||
|
|
df24db62e5 | ||
|
|
90127e98c0 | ||
|
|
79d8d9b4fa | ||
|
|
a4853e73e0 | ||
|
|
b73315791c | ||
|
|
a5126d618a | ||
|
|
9807b9622b |
@@ -44,6 +44,18 @@ const initDB = async () => {
|
||||
// Ignore if it already exists
|
||||
}
|
||||
|
||||
try {
|
||||
await client.query(`ALTER TABLE profiles ADD COLUMN activation_token TEXT`);
|
||||
} catch (err) {
|
||||
// Ignore if it already exists
|
||||
}
|
||||
|
||||
try {
|
||||
await client.query(`ALTER TABLE profiles ADD COLUMN reset_token TEXT`);
|
||||
} catch (err) {
|
||||
// Ignore if it already exists
|
||||
}
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
+32
-4
@@ -4,10 +4,10 @@ require('dotenv').config();
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtp.office365.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports (587 uses STARTTLS)
|
||||
secure: process.env.SMTP_SECURE === 'true' || false, // true for 465, false for other ports (587 uses STARTTLS)
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
user: process.env.SMTP_USER || 'noreply@digifox.studio',
|
||||
pass: process.env.SMTP_PASS || 'pynpzvcdrghqyxlj',
|
||||
},
|
||||
tls: {
|
||||
ciphers: 'SSLv3', // sometimes required for Office365
|
||||
@@ -26,7 +26,7 @@ const transporter = nodemailer.createTransport({
|
||||
const sendMail = async (to, subject, text, html = '') => {
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
from: process.env.SMTP_FROM || 'noreply@digifox.studio',
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
@@ -40,7 +40,35 @@ const sendMail = async (to, subject, text, html = '') => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendActivationEmail = async (email, token, baseUrl) => {
|
||||
const activationLink = `${baseUrl}/activate?token=${token}`;
|
||||
const subject = "Activate your Titan 3D Account";
|
||||
const html = `
|
||||
<h2>Welcome to Titan 3D!</h2>
|
||||
<p>Please click the link below to activate your account:</p>
|
||||
<a href="${activationLink}">${activationLink}</a>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
`;
|
||||
const text = `Welcome to Titan 3D! Please navigate to the following link to activate your account: ${activationLink}`;
|
||||
return sendMail(email, subject, text, html);
|
||||
};
|
||||
|
||||
const sendPasswordResetEmail = async (email, token, baseUrl) => {
|
||||
const resetLink = `${baseUrl}/reset-password?token=${token}`;
|
||||
const subject = "Reset your Titan 3D Password";
|
||||
const html = `
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>Please click the link below to securely reset your password:</p>
|
||||
<a href="${resetLink}">${resetLink}</a>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
`;
|
||||
const text = `Please navigate to the following link to reset your password: ${resetLink}`;
|
||||
return sendMail(email, subject, text, html);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sendMail,
|
||||
sendActivationEmail,
|
||||
sendPasswordResetEmail,
|
||||
transporter
|
||||
};
|
||||
|
||||
+236
-5
@@ -11,8 +11,10 @@ const pdfParse = require('pdf-parse');
|
||||
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
||||
const axios = require('axios');
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { db, run, get, all } = require('./database');
|
||||
const { sendMail } = require('./mail');
|
||||
const { sendMail, sendActivationEmail, sendPasswordResetEmail } = require('./mail');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
@@ -70,13 +72,26 @@ app.post('/api/auth/register', async (req, res) => {
|
||||
const role = parseInt(countRow.count) === 0 ? 'admin' : 'user';
|
||||
const is_active = parseInt(countRow.count) === 0 ? true : false;
|
||||
|
||||
let activation_token = null;
|
||||
if (!is_active) {
|
||||
activation_token = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
await run(
|
||||
'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, email, hash, role, 10, 500, is_active]
|
||||
'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active, activation_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, email, hash, role, 10, 500, is_active, activation_token]
|
||||
);
|
||||
|
||||
if (is_active === false) {
|
||||
return res.json({ status: 'pending', message: 'Account created. Please contact an administrator to activate your account.' });
|
||||
// Send activation email
|
||||
const baseUrl = req.headers.origin || 'http://localhost:5173';
|
||||
try {
|
||||
await sendActivationEmail(email, activation_token, baseUrl);
|
||||
} catch (err) {
|
||||
console.error('Failed to send activation email', err);
|
||||
// We still return pending, maybe they can resend later
|
||||
}
|
||||
return res.json({ status: 'pending', message: 'Account created. Please check your email for the activation link.' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id, email, role }, JWT_SECRET, { expiresIn: '7d' });
|
||||
@@ -87,6 +102,21 @@ app.post('/api/auth/register', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 1.5. Auth Activate
|
||||
app.post('/api/auth/activate', async (req, res) => {
|
||||
const { token } = req.body;
|
||||
if (!token) return res.status(400).json({ error: 'Activation token required' });
|
||||
try {
|
||||
const user = await get('SELECT * FROM profiles WHERE activation_token = ?', [token]);
|
||||
if (!user) return res.status(400).json({ error: 'Invalid or expired activation token' });
|
||||
|
||||
await run('UPDATE profiles SET is_active = true, activation_token = NULL WHERE id = ?', [user.id]);
|
||||
res.json({ success: true, message: 'Account activated successfully.' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Auth Login
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
@@ -221,6 +251,53 @@ app.get('/api/users', authenticate, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 7.1. Add user (Admin only)
|
||||
app.post('/api/users', authenticate, async (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
|
||||
const { email, role, api_credits, storage_limit_mb } = req.body;
|
||||
if (!email) return res.status(400).json({ error: 'Email required' });
|
||||
|
||||
try {
|
||||
const existing = await get('SELECT * FROM profiles WHERE email = ?', [email]);
|
||||
if (existing) return res.status(400).json({ error: 'User already exists' });
|
||||
|
||||
// Generate random temp password (they will reset it anyway)
|
||||
const tempPassword = crypto.randomBytes(16).toString('hex');
|
||||
const hash = await bcrypt.hash(tempPassword, 10);
|
||||
const id = uuidv4();
|
||||
const activation_token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
await run(
|
||||
'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active, activation_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, email, hash, role || 'user', api_credits || 10, storage_limit_mb || 500, false, activation_token]
|
||||
);
|
||||
|
||||
const baseUrl = req.headers.origin || 'http://localhost:5173';
|
||||
try {
|
||||
await sendActivationEmail(email, activation_token, baseUrl);
|
||||
} catch (err) {
|
||||
console.error('Failed to send activation email', err);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'User created and activation email sent.' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 7.2. Delete user (Admin only)
|
||||
app.delete('/api/users/:id', authenticate, async (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
|
||||
try {
|
||||
// Cascade delete projects for the user
|
||||
await run('DELETE FROM projects WHERE user_id = ?', [req.params.id]);
|
||||
await run('DELETE FROM profiles WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 8. Update user (Admin only)
|
||||
app.put('/api/users/:id', authenticate, async (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
|
||||
@@ -233,6 +310,68 @@ app.put('/api/users/:id', authenticate, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 8.1. Admin trigger reset password for user
|
||||
app.post('/api/users/:id/reset-password', authenticate, async (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
|
||||
try {
|
||||
const user = await get('SELECT * FROM profiles WHERE id = ?', [req.params.id]);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const reset_token = crypto.randomBytes(32).toString('hex');
|
||||
await run('UPDATE profiles SET reset_token = ? WHERE id = ?', [reset_token, user.id]);
|
||||
|
||||
const baseUrl = req.headers.origin || 'http://localhost:5173';
|
||||
try {
|
||||
await sendPasswordResetEmail(user.email, reset_token, baseUrl);
|
||||
} catch (err) {
|
||||
console.error('Failed to send reset email', err);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Password reset email sent to user.' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 8.2. Forgot Password (User request)
|
||||
app.post('/api/auth/forgot-password', async (req, res) => {
|
||||
const { email } = req.body;
|
||||
if (!email) return res.status(400).json({ error: 'Email required' });
|
||||
try {
|
||||
const user = await get('SELECT * FROM profiles WHERE email = ?', [email]);
|
||||
if (user) {
|
||||
const reset_token = crypto.randomBytes(32).toString('hex');
|
||||
await run('UPDATE profiles SET reset_token = ? WHERE id = ?', [reset_token, user.id]);
|
||||
const baseUrl = req.headers.origin || 'http://localhost:5173';
|
||||
try {
|
||||
await sendPasswordResetEmail(user.email, reset_token, baseUrl);
|
||||
} catch (err) {
|
||||
console.error('Failed to send reset email', err);
|
||||
}
|
||||
}
|
||||
// Always return success to prevent email enumeration
|
||||
res.json({ success: true, message: 'If an account exists, a reset link has been sent.' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 8.3. Reset Password (User submission)
|
||||
app.post('/api/auth/reset-password', async (req, res) => {
|
||||
const { token, newPassword } = req.body;
|
||||
if (!token || !newPassword) return res.status(400).json({ error: 'Token and new password required' });
|
||||
try {
|
||||
const user = await get('SELECT * FROM profiles WHERE reset_token = ?', [token]);
|
||||
if (!user) return res.status(400).json({ error: 'Invalid or expired reset token' });
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 10);
|
||||
await run('UPDATE profiles SET password_hash = ?, reset_token = NULL WHERE id = ?', [hash, user.id]);
|
||||
res.json({ success: true, message: 'Password has been reset successfully.' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 9. Deduct API credit
|
||||
app.post('/api/users/deduct-credit', authenticate, async (req, res) => {
|
||||
try {
|
||||
@@ -246,6 +385,22 @@ app.post('/api/users/deduct-credit', authenticate, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 9.5 Proxy for downloading GLB files to fix CORS issues in frontend viewers
|
||||
app.get('/api/proxy/glb', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) return res.status(400).json({ error: 'URL required' });
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: 'stream' });
|
||||
res.setHeader('Content-Type', 'model/gltf-binary');
|
||||
// Add CORS headers for the frontend to easily fetch it
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
response.data.pipe(res);
|
||||
} catch (err) {
|
||||
console.error('Proxy error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to proxy file' });
|
||||
}
|
||||
});
|
||||
|
||||
// 10. Upload file
|
||||
app.post('/api/upload', authenticate, upload.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
@@ -492,6 +647,77 @@ CRITICAL INSTRUCTIONS:
|
||||
}
|
||||
});
|
||||
|
||||
// Edit Layer (Gemini Flash Image)
|
||||
app.post('/api/sparkplug/edit-layer', authenticate, async (req, res) => {
|
||||
const { prompt, imageBase64, mimeType } = req.body;
|
||||
if (!prompt || !imageBase64) return res.status(400).json({ error: 'Missing prompt or image' });
|
||||
|
||||
try {
|
||||
const keys = await getKeysFromProfile(req.user.id);
|
||||
const apiKey = keys.GEMINI_API_KEY || keys.NANO_BANANA_API_KEY;
|
||||
if (!apiKey) return res.status(400).json({ error: 'Gemini API key missing' });
|
||||
|
||||
const baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent';
|
||||
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, "");
|
||||
|
||||
const payload = {
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{ text: `You are an expert texture editor. Edit the following image based on this prompt: "${prompt}". \nCRITICAL INSTRUCTIONS:\n1. Output MUST be an image with the exact same aspect ratio as the input.\n2. Do NOT change the layout or mapping of the texture.\n3. Increase the resolution and quality of the visual elements as requested.` },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: mimeType || "image/png",
|
||||
data: base64Data
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
generationConfig: {
|
||||
responseModalities: ["IMAGE"]
|
||||
}
|
||||
};
|
||||
|
||||
const response = await axios.post(`${baseUrl}?key=${apiKey}`, payload, {
|
||||
headers: { 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip,deflate,compress' }
|
||||
});
|
||||
|
||||
let resultBase64 = null;
|
||||
let resultMime = 'image/jpeg';
|
||||
|
||||
const candidate = response.data?.candidates?.[0];
|
||||
if (candidate?.content?.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.inlineData && part.inlineData.data) {
|
||||
resultBase64 = part.inlineData.data;
|
||||
resultMime = part.inlineData.mimeType || 'image/jpeg';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resultBase64) {
|
||||
throw new Error(`Could not extract base64 image data from Gemini response`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(resultBase64, 'base64');
|
||||
const ext = resultMime === 'image/png' ? 'png' : 'jpg';
|
||||
const filename = `layer-edit-${req.user.id}-${Date.now()}.${ext}`;
|
||||
const filepath = path.join(uploadsDir, filename);
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
|
||||
res.json({ imageUrl: `/uploads/${filename}` });
|
||||
} catch (err) {
|
||||
let errorMsg = err.message;
|
||||
if (err.response && err.response.data) {
|
||||
errorMsg = typeof err.response.data === 'object' ? JSON.stringify(err.response.data) : err.response.data;
|
||||
}
|
||||
console.error("Gemini Edit Layer Error:", errorMsg);
|
||||
res.status(500).json({ error: errorMsg });
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Generate 3D (Meshy)
|
||||
app.post('/api/sparkplug/generate-3d', authenticate, async (req, res) => {
|
||||
const { imageUrls } = req.body;
|
||||
@@ -516,7 +742,12 @@ app.post('/api/sparkplug/generate-3d', authenticate, async (req, res) => {
|
||||
|
||||
const response = await axios.post('https://api.meshy.ai/openapi/v1/multi-image-to-3d', {
|
||||
image_urls: base64Images,
|
||||
enable_pbr: true
|
||||
enable_pbr: true,
|
||||
topology: 'quad',
|
||||
target_polycount: 50000,
|
||||
ai_model: 'meshy-6',
|
||||
hd_texture: true,
|
||||
model_type: 'standard'
|
||||
}, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${meshyKey}`,
|
||||
|
||||
@@ -9,6 +9,8 @@ import Home from './pages/Home';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Admin from './pages/Admin';
|
||||
import SparkPlug from './pages/SparkPlug';
|
||||
import Activate from './pages/Activate';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import { AuthModal } from './components/AuthModal';
|
||||
|
||||
export default function App() {
|
||||
@@ -32,6 +34,8 @@ export default function App() {
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/activate" element={<Activate />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
{/* Protected Routes (User) */}
|
||||
<Route element={<ProtectedRoute allowedRoles={['user', 'admin']} />}>
|
||||
|
||||
@@ -26,10 +26,13 @@ export function AuthModal() {
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
if (isRegister) {
|
||||
if (authMode === 'forgot-password') {
|
||||
await api.forgotPassword(email);
|
||||
setSuccess('If an account exists, a reset link has been sent to your email.');
|
||||
} else if (isRegister) {
|
||||
const res = await api.register(email, password);
|
||||
if (res.status === 'pending') {
|
||||
setSuccess(res.message || 'Account created successfully! Please contact an admin to activate your account.');
|
||||
setSuccess(res.message || 'Account created successfully! Please check your email for the activation link.');
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
} else {
|
||||
@@ -84,10 +87,10 @@ export function AuthModal() {
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-white text-center mb-2 relative z-10">
|
||||
{isRegister ? 'Create an Account' : 'Welcome Back'}
|
||||
{authMode === 'forgot-password' ? 'Reset Password' : isRegister ? 'Create an Account' : 'Welcome Back'}
|
||||
</h2>
|
||||
<p className="text-zinc-400 text-sm text-center mb-8 relative z-10">
|
||||
{isRegister ? 'Sign up to start building 3D scenes.' : 'Sign in to access your projects.'}
|
||||
{authMode === 'forgot-password' ? 'Enter your email to receive a reset link.' : isRegister ? 'Sign up to start building 3D scenes.' : 'Sign in to access your projects.'}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
@@ -116,6 +119,7 @@ export function AuthModal() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authMode !== 'forgot-password' && (
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
|
||||
<input
|
||||
@@ -128,6 +132,15 @@ export function AuthModal() {
|
||||
className="w-full bg-zinc-950/50 border border-zinc-700/50 rounded-xl py-3 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authMode === 'login' && (
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => openAuthModal('forgot-password')} className="text-xs text-primary hover:underline">
|
||||
Forgot Password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
@@ -135,12 +148,14 @@ export function AuthModal() {
|
||||
className="w-full bg-primary hover:bg-primary/90 text-white font-bold py-3 rounded-xl mt-2 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-[0_0_20px_rgba(var(--primary-rgb),0.3)] hover:shadow-[0_0_30px_rgba(var(--primary-rgb),0.5)]"
|
||||
>
|
||||
{loading && <Loader2 size={18} className="animate-spin" />}
|
||||
{isRegister ? 'Sign Up' : 'Sign In'}
|
||||
{authMode === 'forgot-password' ? 'Send Reset Link' : isRegister ? 'Sign Up' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-zinc-400 relative z-10">
|
||||
{isRegister ? (
|
||||
{authMode === 'forgot-password' ? (
|
||||
<p>Remember your password? <button onClick={() => openAuthModal('login')} className="text-primary hover:underline font-medium">Sign in</button></p>
|
||||
) : isRegister ? (
|
||||
<p>Already have an account? <button onClick={() => openAuthModal('login')} className="text-primary hover:underline font-medium">Sign in</button></p>
|
||||
) : (
|
||||
<p>Don't have an account? <button onClick={() => openAuthModal('register')} className="text-primary hover:underline font-medium">Sign up</button></p>
|
||||
|
||||
+201
-344
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Upload, Settings as SettingsIcon, Image as ImageIcon, Box, Download, AlertCircle, Loader2, Check } from 'lucide-react';
|
||||
import { fal } from '@fal-ai/client';
|
||||
import { X, Upload, Settings as SettingsIcon, Box, AlertCircle, Loader2, RefreshCw, Check } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { api } from '../lib/api';
|
||||
import { editUVLayer } from '../lib/api/apiUtils';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Stage } from '@react-three/drei';
|
||||
import { GLTFLoader } from 'three-stdlib';
|
||||
@@ -16,13 +16,6 @@ interface ImageTo3DModalProps {
|
||||
onPushToApp: (modelUrl: string) => void;
|
||||
}
|
||||
|
||||
const MODELS = [
|
||||
{ id: 'fal-ai/hunyuan3d/v2/image-to-3d', name: 'Hunyuan3D-2 (Latest)' },
|
||||
{ id: 'fal-ai/hunyuan3d/v1', name: 'Hunyuan3D-1' },
|
||||
{ id: 'fal-ai/meshy/v6/image-to-3d', name: 'Meshy v6' },
|
||||
{ id: 'fal-ai/tripo3d', name: 'Tripo3D' }
|
||||
];
|
||||
|
||||
const ModelPreview = ({ url }: { url: string }) => {
|
||||
const [model, setModel] = useState<THREE.Group | null>(null);
|
||||
|
||||
@@ -36,7 +29,7 @@ const ModelPreview = ({ url }: { url: string }) => {
|
||||
if (!model) return <div className="absolute inset-0 flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-zinc-500" /></div>;
|
||||
|
||||
return (
|
||||
<Canvas shadows camera={{ position: [0, 1.5, 3], fov: 45 }}>
|
||||
<Canvas shadows={{ type: THREE.PCFShadowMap }} camera={{ position: [0, 1.5, 3], fov: 45 }}>
|
||||
<Stage environment="studio" intensity={0.5}>
|
||||
<primitive object={model} />
|
||||
</Stage>
|
||||
@@ -47,13 +40,15 @@ const ModelPreview = ({ url }: { url: string }) => {
|
||||
|
||||
export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose, onPushToApp }) => {
|
||||
const { profile, setProfile } = useAuthStore();
|
||||
const [apiProvider, setApiProvider] = useState<'fal' | 'tripo' | 'meshy'>('fal');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [tripoApiKey, setTripoApiKey] = useState('');
|
||||
const [meshyApiKey, setMeshyApiKey] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState(MODELS[0].id);
|
||||
|
||||
// Image states
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [originalImagePreview, setOriginalImagePreview] = useState<string | null>(null);
|
||||
|
||||
const [cleanedImageFile, setCleanedImageFile] = useState<File | null>(null);
|
||||
const [cleanedImagePreview, setCleanedImagePreview] = useState<string | null>(null);
|
||||
const [isCleaningImage, setIsCleaningImage] = useState(false);
|
||||
|
||||
// Meshy specific state
|
||||
const [meshyModelType, setMeshyModelType] = useState('standard');
|
||||
@@ -73,68 +68,54 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
const [generatedModelUrl, setGeneratedModelUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isValidatingKey, setIsValidatingKey] = useState(false);
|
||||
const [keyValidationStatus, setKeyValidationStatus] = useState<'idle' | 'valid' | 'invalid' | 'error'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
setKeyValidationStatus('idle');
|
||||
}, [apiKey, tripoApiKey, meshyApiKey, apiProvider]);
|
||||
|
||||
const validateKey = async () => {
|
||||
setIsValidatingKey(true);
|
||||
setKeyValidationStatus('idle');
|
||||
try {
|
||||
if (apiProvider === 'fal') {
|
||||
const res = await fetch('https://fal.run/', {
|
||||
headers: { Authorization: `Key ${apiKey}` }
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) setKeyValidationStatus('invalid');
|
||||
else setKeyValidationStatus('valid');
|
||||
} else if (apiProvider === 'tripo') {
|
||||
const res = await fetch('https://api.tripo3d.ai/v2/openapi/task', {
|
||||
headers: { Authorization: `Bearer ${tripoApiKey}` }
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) setKeyValidationStatus('invalid');
|
||||
else setKeyValidationStatus('valid');
|
||||
} else if (apiProvider === 'meshy') {
|
||||
const res = await fetch('https://api.meshy.ai/openapi/v1/image-to-3d', {
|
||||
headers: { Authorization: `Bearer ${meshyApiKey}` }
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) setKeyValidationStatus('invalid');
|
||||
else setKeyValidationStatus('valid');
|
||||
if (isOpen) {
|
||||
api.getUserKeys().then(keys => {
|
||||
if (keys.MESHY_API_KEY) setMeshyApiKey(keys.MESHY_API_KEY);
|
||||
}).catch(err => console.error("Failed to load user keys", err));
|
||||
}
|
||||
} catch (e) {
|
||||
setKeyValidationStatus('error');
|
||||
} finally {
|
||||
setIsValidatingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedProvider = localStorage.getItem('API_PROVIDER') as 'fal' | 'tripo' | 'meshy' | null;
|
||||
if (savedProvider) setApiProvider(savedProvider);
|
||||
const savedKey = localStorage.getItem('FAL_API_KEY');
|
||||
if (savedKey) setApiKey(savedKey);
|
||||
const savedTripoKey = localStorage.getItem('TRIPO_API_KEY');
|
||||
if (savedTripoKey) setTripoApiKey(savedTripoKey);
|
||||
const savedMeshyKey = localStorage.getItem('MESHY_API_KEY');
|
||||
if (savedMeshyKey) setMeshyApiKey(savedMeshyKey);
|
||||
const savedModel = localStorage.getItem('FAL_MODEL');
|
||||
if (savedModel) setSelectedModel(savedModel);
|
||||
}, []);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setImageFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setImagePreview(url);
|
||||
setOriginalImagePreview(url);
|
||||
setGeneratedModelUrl(null);
|
||||
setError(null);
|
||||
|
||||
// Auto clean on upload
|
||||
handleCleanImage(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanImage = async (fileToClean: File) => {
|
||||
setIsCleaningImage(true);
|
||||
setCleanedImageFile(null);
|
||||
setCleanedImagePreview(null);
|
||||
try {
|
||||
const base64 = await fileToDataUrl(fileToClean);
|
||||
const token = localStorage.getItem('auth_token') || '';
|
||||
const newUrl = await editUVLayer("Remove the background and make it solid transparent or white. Also carefully remove all text, labels, logos, and stickers, keeping the product as a perfectly clean, plain, unbranded object.", base64, token);
|
||||
|
||||
const res = await fetch(newUrl);
|
||||
const blob = await res.blob();
|
||||
const newFile = new File([blob], "cleaned-image.png", { type: "image/png" });
|
||||
|
||||
setCleanedImageFile(newFile);
|
||||
setCleanedImagePreview(newUrl);
|
||||
} catch (err: any) {
|
||||
setError("Failed to clean image: " + err.message);
|
||||
// Fallback to original
|
||||
setCleanedImageFile(fileToClean);
|
||||
setCleanedImagePreview(URL.createObjectURL(fileToClean));
|
||||
} finally {
|
||||
setIsCleaningImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Utility to convert file to data URL for Fal API if needed
|
||||
const fileToDataUrl = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -145,7 +126,8 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
};
|
||||
|
||||
const generate3DModel = async () => {
|
||||
if (!imageFile) {
|
||||
const targetFile = cleanedImageFile || imageFile;
|
||||
if (!targetFile) {
|
||||
setError("Please upload an image first.");
|
||||
return;
|
||||
}
|
||||
@@ -153,15 +135,7 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
setError("Not enough AI Credits. Please contact an administrator.");
|
||||
return;
|
||||
}
|
||||
if (apiProvider === 'fal' && !apiKey) {
|
||||
setError("Fal API Key is required. Please set it in Tools > API Settings.");
|
||||
return;
|
||||
}
|
||||
if (apiProvider === 'tripo' && !tripoApiKey) {
|
||||
setError("Tripo API Key is required. Please set it in Tools > API Settings.");
|
||||
return;
|
||||
}
|
||||
if (apiProvider === 'meshy' && !meshyApiKey) {
|
||||
if (!meshyApiKey) {
|
||||
setError("Meshy API Key is required. Please set it in Tools > API Settings.");
|
||||
return;
|
||||
}
|
||||
@@ -180,121 +154,8 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setProgressMessage("Uploading image...");
|
||||
|
||||
if (apiProvider === 'fal') {
|
||||
fal.config({ credentials: apiKey });
|
||||
const dataUrl = await fileToDataUrl(imageFile);
|
||||
setProgressMessage("Generating 3D Model... This might take up to a minute.");
|
||||
|
||||
let inputPayload: any = { image_url: dataUrl };
|
||||
if (selectedModel.includes('tripo')) {
|
||||
inputPayload = { image_url: dataUrl };
|
||||
}
|
||||
|
||||
const result = await fal.subscribe(selectedModel, {
|
||||
input: inputPayload,
|
||||
logs: true,
|
||||
onQueueUpdate: (update) => {
|
||||
if (update.status === "IN_PROGRESS") {
|
||||
update.logs?.map((log) => setProgressMessage(log.message));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Fal API Result:", result);
|
||||
|
||||
let modelUrl = null;
|
||||
if (result.data) {
|
||||
if (result.data.model_3d && result.data.model_3d.url) {
|
||||
modelUrl = result.data.model_3d.url; // Hunyuan v2
|
||||
} else if (result.data.video && result.data.video.url && result.data.video.url.endsWith('.glb')) {
|
||||
modelUrl = result.data.video.url;
|
||||
} else if (result.data.mesh_url) {
|
||||
modelUrl = result.data.mesh_url;
|
||||
} else if (result.data.model_url) {
|
||||
modelUrl = result.data.model_url;
|
||||
}
|
||||
}
|
||||
|
||||
if (modelUrl) {
|
||||
setGeneratedModelUrl(modelUrl);
|
||||
setProgressMessage("Done!");
|
||||
deductCredit();
|
||||
} else {
|
||||
setError("Failed to extract 3D model from response.");
|
||||
}
|
||||
} else if (apiProvider === 'tripo') {
|
||||
// TRIPO NATIVE API
|
||||
const formData = new FormData();
|
||||
formData.append('file', imageFile);
|
||||
|
||||
const uploadRes = await fetch('https://api.tripo3d.ai/v2/openapi/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tripoApiKey}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) throw new Error("Tripo API: Upload failed.");
|
||||
const uploadData = await uploadRes.json();
|
||||
if (uploadData.code !== 0) throw new Error(`Tripo API Upload Error: ${uploadData.message || 'Unknown'}`);
|
||||
|
||||
const imageToken = uploadData.data.image_token;
|
||||
setProgressMessage("Generating 3D Model with Tripo3D... This might take up to a minute.");
|
||||
|
||||
const taskRes = await fetch('https://api.tripo3d.ai/v2/openapi/task', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${tripoApiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "image_to_model",
|
||||
file: {
|
||||
type: imageFile.type.includes('png') ? 'png' : 'jpg',
|
||||
file_token: imageToken
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!taskRes.ok) throw new Error("Tripo API: Task creation failed.");
|
||||
const taskData = await taskRes.json();
|
||||
if (taskData.code !== 0) throw new Error(`Tripo API Task Error: ${taskData.message || 'Unknown'}`);
|
||||
|
||||
const taskId = taskData.data.task_id;
|
||||
|
||||
let isDone = false;
|
||||
while (!isDone) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const pollRes = await fetch(`https://api.tripo3d.ai/v2/openapi/task/${taskId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tripoApiKey}`
|
||||
}
|
||||
});
|
||||
if (!pollRes.ok) throw new Error("Tripo API: Polling failed.");
|
||||
const pollData = await pollRes.json();
|
||||
if (pollData.code !== 0) throw new Error(`Tripo API Poll Error: ${pollData.message}`);
|
||||
|
||||
const status = pollData.data.status;
|
||||
const progress = pollData.data.progress || 0;
|
||||
setProgressMessage(`Generating... ${progress}%`);
|
||||
|
||||
if (status === 'success') {
|
||||
const modelUrl = pollData.data.result.model.url || (pollData.data.result.pbr && pollData.data.result.pbr.model && pollData.data.result.pbr.model.url);
|
||||
if (!modelUrl) throw new Error("Tripo API: Model URL not found in result.");
|
||||
setGeneratedModelUrl(modelUrl);
|
||||
setProgressMessage("Done!");
|
||||
isDone = true;
|
||||
deductCredit();
|
||||
} else if (status === 'failed' || status === 'cancelled') {
|
||||
throw new Error(`Tripo API Task ${status}`);
|
||||
}
|
||||
}
|
||||
} else if (apiProvider === 'meshy') {
|
||||
// MESHY NATIVE API
|
||||
const dataUrl = await fileToDataUrl(imageFile);
|
||||
const dataUrl = await fileToDataUrl(targetFile);
|
||||
setProgressMessage("Generating 3D Model with Meshy... This might take up to a minute.");
|
||||
|
||||
let payload: any = {
|
||||
@@ -306,7 +167,7 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
};
|
||||
|
||||
if (meshyModelType !== 'lowpoly') {
|
||||
payload.ai_model = meshyAiModel;
|
||||
payload.ai_model = meshyAiModel === 'latest' ? 'meshy-6' : meshyAiModel;
|
||||
payload.should_remesh = meshyShouldRemesh;
|
||||
if (meshyShouldRemesh) {
|
||||
payload.topology = meshyTopology;
|
||||
@@ -335,7 +196,11 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!taskRes.ok) throw new Error("Meshy API: Task creation failed.");
|
||||
if (!taskRes.ok) {
|
||||
const errData = await taskRes.text();
|
||||
console.error("Meshy task creation error payload:", errData);
|
||||
throw new Error(`Meshy API: Task creation failed. ${errData}`);
|
||||
}
|
||||
const taskData = await taskRes.json();
|
||||
const taskId = taskData.result;
|
||||
|
||||
@@ -350,20 +215,19 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
if (!pollRes.ok) throw new Error("Meshy API: Polling failed.");
|
||||
const pollData = await pollRes.json();
|
||||
|
||||
const status = pollData.status;
|
||||
const status = (pollData.status || '').toUpperCase();
|
||||
const progress = pollData.progress || 0;
|
||||
setProgressMessage(`Generating... ${progress}%`);
|
||||
|
||||
if (status === 'SUCCEEDED') {
|
||||
const modelUrl = pollData.model_urls?.glb;
|
||||
if (!modelUrl) throw new Error("Meshy API: Model URL not found in result.");
|
||||
setGeneratedModelUrl(modelUrl);
|
||||
setGeneratedModelUrl(`/api/proxy/glb?url=${encodeURIComponent(modelUrl)}`);
|
||||
setProgressMessage("Done!");
|
||||
isDone = true;
|
||||
deductCredit();
|
||||
} else if (status === 'FAILED' || status === 'EXPIRED') {
|
||||
throw new Error(`Meshy API Task ${status}`);
|
||||
}
|
||||
throw new Error(`Meshy API Task ${status}: ${pollData.task_error?.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -383,116 +247,97 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="w-full max-w-4xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[600px]"
|
||||
className="w-full max-w-5xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[700px]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-12 bg-zinc-800 flex items-center justify-between px-6 border-b border-zinc-700">
|
||||
<div className="h-14 bg-zinc-800 flex items-center justify-between px-6 border-b border-zinc-700">
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<Box size={18} />
|
||||
<span className="text-sm font-bold text-zinc-200">Image to 3D Generation</span>
|
||||
<Box size={20} />
|
||||
<span className="text-base font-bold text-zinc-200">Meshy 3D Generation</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-zinc-700 rounded transition-colors">
|
||||
<X size={16} className="text-zinc-400" />
|
||||
<button onClick={onClose} className="p-2 hover:bg-zinc-700 rounded transition-colors">
|
||||
<X size={18} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto bg-zinc-900/50 p-6">
|
||||
<div className="grid grid-cols-2 gap-6 h-full">
|
||||
{/* Left Column: Upload */}
|
||||
<div className="grid grid-cols-2 gap-8 h-full">
|
||||
{/* Left Column: Upload & Clean */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Provider & Model</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={apiProvider}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as 'fal' | 'tripo' | 'meshy';
|
||||
setApiProvider(val);
|
||||
localStorage.setItem('API_PROVIDER', val);
|
||||
}}
|
||||
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-blue-500/50"
|
||||
>
|
||||
<option value="fal">Fal.ai</option>
|
||||
<option value="tripo">Tripo3D</option>
|
||||
<option value="meshy">Meshy</option>
|
||||
</select>
|
||||
{apiProvider === 'fal' && (
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => {
|
||||
setSelectedModel(e.target.value);
|
||||
localStorage.setItem('FAL_MODEL', e.target.value);
|
||||
}}
|
||||
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-blue-500/50"
|
||||
>
|
||||
{MODELS.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-zinc-200">Input Image</h3>
|
||||
<h3 className="text-sm font-bold text-zinc-200">Input Image (Auto Cleaned)</h3>
|
||||
<span className="text-[10px] text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded-full">Front View</span>
|
||||
</div>
|
||||
|
||||
<label className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-zinc-700 hover:border-zinc-500 rounded-xl bg-zinc-900/50 cursor-pointer transition-colors relative overflow-hidden group">
|
||||
{!originalImagePreview ? (
|
||||
<label className="flex-1 min-h-[220px] flex flex-col items-center justify-center border-2 border-dashed border-zinc-700 hover:border-zinc-500 rounded-xl bg-zinc-900/50 cursor-pointer transition-colors relative overflow-hidden group">
|
||||
<input type="file" ref={fileInputRef} accept="image/png, image/jpeg" className="hidden" onChange={handleImageUpload} />
|
||||
{imagePreview ? (
|
||||
<>
|
||||
<img src={imagePreview} alt="Preview" className="absolute inset-0 w-full h-full object-contain p-2" />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||
<span className="text-white text-xs font-medium flex items-center gap-2"><Upload size={14}/> Change Image</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setGeneratedModelUrl(null);
|
||||
setError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1.5 bg-red-500/80 hover:bg-red-500 text-white rounded-md backdrop-blur shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
|
||||
title="Remove image"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center text-zinc-500 gap-3 pointer-events-none p-6 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center">
|
||||
<Upload size={20} className="text-zinc-400" />
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-800 flex items-center justify-center">
|
||||
<Upload size={24} className="text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-300 mb-1">Click to upload image</p>
|
||||
<p className="text-xs">PNG, JPG up to 10MB</p>
|
||||
<p className="text-[10px] mt-2 text-zinc-600">Tip: Use an image with a solid or transparent background.</p>
|
||||
<p className="text-base font-medium text-zinc-300 mb-1">Click to upload image</p>
|
||||
<p className="text-sm">PNG, JPG up to 10MB</p>
|
||||
<p className="text-xs mt-3 text-blue-400 font-medium">Image will be automatically cleaned of text/logos.</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<div className="flex gap-4 h-[220px]">
|
||||
{/* Original Image */}
|
||||
<label className="flex-1 relative border-2 border-dashed border-zinc-700 rounded-xl overflow-hidden group cursor-pointer">
|
||||
<input type="file" ref={fileInputRef} accept="image/png, image/jpeg" className="hidden" onChange={handleImageUpload} />
|
||||
<img src={originalImagePreview} alt="Original" className="w-full h-full object-contain p-2" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/80 text-center text-xs text-zinc-400 py-1.5 border-t border-zinc-800">Original</div>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||
<span className="text-white text-sm font-medium flex items-center gap-2"><Upload size={16}/> Change Image</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Cleaned Image */}
|
||||
<div className="flex-1 relative border-2 border-solid border-zinc-700 rounded-xl overflow-hidden bg-zinc-900/50 flex items-center justify-center group">
|
||||
{isCleaningImage ? (
|
||||
<>
|
||||
<img src={originalImagePreview} alt="Original Grayscale" className="absolute inset-0 w-full h-full object-contain p-2 opacity-30 grayscale blur-sm" />
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-300 gap-3 bg-black/40 z-10">
|
||||
<Loader2 size={28} className="animate-spin text-blue-500" />
|
||||
<span className="text-sm font-medium">AI Cleaning Image...</span>
|
||||
</div>
|
||||
</>
|
||||
) : cleanedImagePreview ? (
|
||||
<>
|
||||
<img src={cleanedImagePreview} alt="Cleaned" className="w-full h-full object-contain p-2" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-blue-900/80 text-center text-xs text-blue-200 py-1.5 font-bold border-t border-blue-800">Cleaned Result</div>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex flex-col items-center justify-center gap-3 transition-opacity">
|
||||
<button onClick={(e) => { e.preventDefault(); handleCleanImage(imageFile!); }} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold shadow-lg flex items-center gap-2 transition-colors">
|
||||
<RefreshCw size={14} /> Try Again
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-600 text-sm">Failed to clean.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{apiProvider === 'meshy' && (
|
||||
<div className="flex flex-col gap-3 mt-2 bg-zinc-900 p-3 rounded-xl border border-zinc-800">
|
||||
<h4 className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider flex items-center gap-1"><SettingsIcon size={12}/> Meshy Settings</h4>
|
||||
<div className="flex flex-col gap-3 mt-4 bg-zinc-900 p-4 rounded-xl border border-zinc-800">
|
||||
<h4 className="text-xs font-bold text-zinc-400 uppercase tracking-wider flex items-center gap-1.5"><SettingsIcon size={14}/> Meshy Config</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-zinc-500 font-bold">Model Type</label>
|
||||
<select value={meshyModelType} onChange={e => setMeshyModelType(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300">
|
||||
<div className="grid grid-cols-2 gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-zinc-500 font-bold">Model Type</label>
|
||||
<select value={meshyModelType} onChange={e => setMeshyModelType(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="lowpoly">Low Poly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-zinc-500 font-bold">AI Model</label>
|
||||
<select value={meshyAiModel} onChange={e => setMeshyAiModel(e.target.value)} disabled={meshyModelType === 'lowpoly'} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300 disabled:opacity-50">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-zinc-500 font-bold">AI Model</label>
|
||||
<select value={meshyAiModel} onChange={e => setMeshyAiModel(e.target.value)} disabled={meshyModelType === 'lowpoly'} className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300 disabled:opacity-50">
|
||||
<option value="latest">Latest (Meshy 6)</option>
|
||||
<option value="meshy-6">Meshy 6</option>
|
||||
<option value="meshy-5">Meshy 5</option>
|
||||
@@ -500,116 +345,128 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer">
|
||||
<input type="checkbox" checked={meshyShouldTexture} onChange={e => setMeshyShouldTexture(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<div className="flex items-center gap-5 mt-2">
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer">
|
||||
<input type="checkbox" checked={meshyShouldTexture} onChange={e => setMeshyShouldTexture(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
Texture
|
||||
</label>
|
||||
<label className={cn("flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer", !meshyShouldTexture && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyEnablePbr} onChange={e => setMeshyEnablePbr(e.target.checked)} disabled={!meshyShouldTexture} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<label className={cn("flex items-center gap-2 text-sm text-zinc-300 cursor-pointer", !meshyShouldTexture && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyEnablePbr} onChange={e => setMeshyEnablePbr(e.target.checked)} disabled={!meshyShouldTexture} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
PBR
|
||||
</label>
|
||||
<label className={cn("flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer", (!meshyShouldTexture || meshyAiModel === 'meshy-5') && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyHdTexture} onChange={e => setMeshyHdTexture(e.target.checked)} disabled={!meshyShouldTexture || meshyAiModel === 'meshy-5'} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<label className={cn("flex items-center gap-2 text-sm text-zinc-300 cursor-pointer", (!meshyShouldTexture || meshyAiModel === 'meshy-5') && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyHdTexture} onChange={e => setMeshyHdTexture(e.target.checked)} disabled={!meshyShouldTexture || meshyAiModel === 'meshy-5'} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
4K HD
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{meshyShouldTexture && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input type="text" value={meshyTexturePrompt} onChange={e => setMeshyTexturePrompt(e.target.value)} maxLength={600} placeholder="Texture prompt (optional)..." className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" />
|
||||
<input type="text" value={meshyTextureImageUrl} onChange={e => setMeshyTextureImageUrl(e.target.value)} placeholder="Texture Image URL (optional)..." className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" />
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<input type="text" value={meshyTexturePrompt} onChange={e => setMeshyTexturePrompt(e.target.value)} maxLength={600} placeholder="Texture prompt (optional)..." className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<label className={cn("flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer", meshyModelType === 'lowpoly' && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyShouldRemesh} onChange={e => setMeshyShouldRemesh(e.target.checked)} disabled={meshyModelType === 'lowpoly'} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<div className="flex items-center gap-5 mt-2">
|
||||
<label className={cn("flex items-center gap-2 text-sm text-zinc-300 cursor-pointer", meshyModelType === 'lowpoly' && "opacity-50 pointer-events-none")}>
|
||||
<input type="checkbox" checked={meshyShouldRemesh} onChange={e => setMeshyShouldRemesh(e.target.checked)} disabled={meshyModelType === 'lowpoly'} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
Remesh
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer" title="Keep enabled to prevent flat text/logos from becoming embossed geometry">
|
||||
<input type="checkbox" checked={meshyEnhanceImage} onChange={e => setMeshyEnhanceImage(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer" title="Keep enabled to prevent flat text/logos from becoming embossed geometry">
|
||||
<input type="checkbox" checked={meshyEnhanceImage} onChange={e => setMeshyEnhanceImage(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
Enhance Image
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{meshyShouldRemesh && meshyModelType !== 'lowpoly' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-zinc-500 font-bold">Topology</label>
|
||||
<select value={meshyTopology} onChange={e => setMeshyTopology(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300">
|
||||
<div className="grid grid-cols-2 gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-zinc-500 font-bold">Topology</label>
|
||||
<select value={meshyTopology} onChange={e => setMeshyTopology(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300">
|
||||
<option value="triangle">Triangle</option>
|
||||
<option value="quad">Quad</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] text-zinc-500 font-bold">Polycount</label>
|
||||
<input type="number" min={100} max={300000} step={1000} value={meshyTargetPolycount} onChange={e => setMeshyTargetPolycount(parseInt(e.target.value) || 30000)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-zinc-500 font-bold">Polycount</label>
|
||||
<input type="number" min={100} max={300000} step={1000} value={meshyTargetPolycount} onChange={e => setMeshyTargetPolycount(parseInt(e.target.value) || 30000)} className="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex gap-2 items-start text-red-400 text-xs">
|
||||
<AlertCircle size={14} className="shrink-0 mt-0.5" />
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex gap-2 items-start text-red-400 text-sm mt-auto">
|
||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column: 3D Preview */}
|
||||
<div className="flex flex-col h-full bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800 relative">
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<h3 className="text-sm font-bold text-zinc-200">3D Preview</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative flex items-center justify-center overflow-hidden">
|
||||
{isGenerating ? (
|
||||
<>
|
||||
{cleanedImagePreview || originalImagePreview ? (
|
||||
<img src={(cleanedImagePreview || originalImagePreview) as string} alt="Generating Base" className="absolute inset-0 w-full h-full object-contain p-8 opacity-20 grayscale blur-md scale-110" />
|
||||
) : null}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-300 bg-black/60 z-20">
|
||||
<Loader2 size={36} className="animate-spin text-blue-500 mb-4" />
|
||||
<p className="text-sm font-medium text-zinc-200">{progressMessage}</p>
|
||||
</div>
|
||||
</>
|
||||
) : generatedModelUrl ? (
|
||||
<ModelPreview url={generatedModelUrl} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-zinc-600 p-6 text-center">
|
||||
<Box size={32} className="mb-3 opacity-50" />
|
||||
<p className="text-sm">Your 3D model will appear here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="h-16 bg-zinc-900 border-t border-zinc-800 px-6 flex items-center justify-between shrink-0">
|
||||
<div className="text-xs font-medium px-3 py-1.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
Meshy Generation: 200 AI Credits
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-bold transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{generatedModelUrl ? (
|
||||
<button
|
||||
onClick={() => onPushToApp(generatedModelUrl)}
|
||||
className="px-6 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-green-900/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Check size={16} />
|
||||
Use Model
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={generate3DModel}
|
||||
disabled={!imageFile || isGenerating}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-blue-900/20 transition-all flex items-center justify-center gap-2"
|
||||
disabled={!imageFile || isGenerating || isCleaningImage}
|
||||
className="px-8 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-900/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
{isGenerating ? <Loader2 size={16} className="animate-spin" /> : <Box size={16} />}
|
||||
{isGenerating ? 'Generating...' : 'Generate 3D Model'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Output */}
|
||||
<div className="flex flex-col gap-4 border-l border-zinc-800 pl-6">
|
||||
<h3 className="text-sm font-bold text-zinc-200">3D Preview</h3>
|
||||
<div className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl relative overflow-hidden flex flex-col items-center justify-center">
|
||||
{generatedModelUrl ? (
|
||||
<>
|
||||
<div className="absolute inset-0">
|
||||
<ModelPreview url={generatedModelUrl} />
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 flex gap-2 z-10">
|
||||
<a href={generatedModelUrl} download="model.glb" target="_blank" className="p-2 bg-zinc-900/80 hover:bg-zinc-800 backdrop-blur rounded-lg border border-zinc-700 transition-colors text-zinc-300">
|
||||
<Download size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : isGenerating ? (
|
||||
<div className="flex flex-col items-center gap-4 text-zinc-400">
|
||||
<Loader2 size={32} className="animate-spin text-blue-500" />
|
||||
<p className="text-xs font-medium animate-pulse">{progressMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-zinc-600 flex flex-col items-center gap-2">
|
||||
<Box size={32} className="opacity-20" />
|
||||
<p className="text-xs">Your 3D model will appear here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generatedModelUrl && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onPushToApp(generatedModelUrl);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full py-3 bg-green-600 hover:bg-green-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-green-900/20 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
Push to Application Scene
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,13 @@ import * as THREE from "three";
|
||||
import { useFrame, useThree, createPortal } from "@react-three/fiber";
|
||||
import { MaterialFactory } from "../materials/MaterialFactory";
|
||||
import { useTexturePipeline } from "../hooks/useTexturePipeline";
|
||||
import { reprojectUVs, bakeTextureToNewUVs } from '../lib/bakingUtils';
|
||||
|
||||
interface ModelViewerProps {
|
||||
object: THREE.Object3D | null;
|
||||
sceneObjects: any[];
|
||||
bakeRequest?: { id: string, type: 'planar' | 'cylinder' | 'sphere' | 'box', flipX?: boolean, flipY?: boolean, timestamp: number } | null;
|
||||
onBakeComplete?: (dataUrl: string) => void;
|
||||
}
|
||||
|
||||
interface MeshMaterialManagerProps {
|
||||
@@ -25,6 +28,12 @@ const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManag
|
||||
const uvTiling = props.uvTiling || [1, 1];
|
||||
const uvOffset = props.uvOffset || [0, 0];
|
||||
const uvRotation = props.uvRotation || 0;
|
||||
const bakedMappingType = props.bakedMappingType;
|
||||
|
||||
// Automatically re-apply baked UV projections on load
|
||||
if (bakedMappingType && ['planar', 'cylinder', 'sphere', 'box'].includes(bakedMappingType)) {
|
||||
reprojectUVs(mesh.geometry, bakedMappingType as any);
|
||||
}
|
||||
|
||||
const origMat = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material) as THREE.MeshPhysicalMaterial;
|
||||
|
||||
@@ -58,7 +67,7 @@ const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManag
|
||||
|
||||
activeMat.triplanarUniforms.uTriplanarScale.value = uvScale;
|
||||
activeMat.triplanarUniforms.uBlendSharpness.value = 5.0; // Default or from props
|
||||
} else {
|
||||
} else if (mappingType !== 'uv') {
|
||||
activeMat = MaterialFactory.getInstance().getStandardUVMaterial(
|
||||
baseParams,
|
||||
mesh.uuid
|
||||
@@ -71,6 +80,9 @@ const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManag
|
||||
|
||||
activeMat.uvUniforms.uMappingMode.value = modeIndex;
|
||||
activeMat.updateUVTransform(uvOffset, [uvTiling[0] * uvScale, uvTiling[1] * uvScale], uvRotation * (Math.PI / 180));
|
||||
} else {
|
||||
// Use the native material to preserve exact GLTF UV behavior
|
||||
activeMat = origMat;
|
||||
}
|
||||
|
||||
// Apply basic props
|
||||
@@ -103,6 +115,7 @@ const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManag
|
||||
tex.name = url;
|
||||
if (type === 'map') tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.flipY = false;
|
||||
|
||||
textureCache.current.set(url, tex);
|
||||
activeMat[type] = tex;
|
||||
@@ -116,6 +129,14 @@ const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManag
|
||||
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = null;
|
||||
activeMat.needsUpdate = true;
|
||||
}
|
||||
|
||||
// Native UV mapping transforms
|
||||
if (mappingType === 'uv' && activeMat[type]) {
|
||||
activeMat[type].repeat.set(uvTiling[0] * uvScale, uvTiling[1] * uvScale);
|
||||
activeMat[type].offset.set(uvOffset[0], uvOffset[1]);
|
||||
activeMat[type].rotation = uvRotation * (Math.PI / 180);
|
||||
activeMat[type].needsUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
mesh.visible = sceneObj.visible;
|
||||
@@ -199,7 +220,9 @@ const FillMesh = ({ fillProps, originalMesh }: { fillProps: any, originalMesh: T
|
||||
return createPortal(fillNode, originalMesh);
|
||||
};
|
||||
|
||||
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects }, ref) => {
|
||||
|
||||
|
||||
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects, bakeRequest, onBakeComplete }, ref) => {
|
||||
const internalRef = useRef<THREE.Group>(null);
|
||||
const groupRef = (ref as any) || internalRef;
|
||||
const textureCache = useRef<Map<string, THREE.Texture>>(new Map());
|
||||
@@ -229,6 +252,43 @@ export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { gl } = useThree();
|
||||
|
||||
// Handle Baking Request
|
||||
useEffect(() => {
|
||||
if (bakeRequest && onBakeComplete && meshCache.size > 0) {
|
||||
const sceneObj = sceneObjects.find(obj => obj.id === bakeRequest.id);
|
||||
if (!sceneObj) return;
|
||||
|
||||
// Find the primary mesh for this object (we assume the first mesh for now)
|
||||
let targetMesh: THREE.Mesh | null = null;
|
||||
for (const mesh of meshCache.values()) {
|
||||
targetMesh = mesh;
|
||||
break;
|
||||
}
|
||||
|
||||
if (targetMesh && sceneObj.materialProps?.map) {
|
||||
const tex = textureCache.current.get(sceneObj.materialProps.map);
|
||||
if (tex) {
|
||||
// Keep old UVs
|
||||
const oldUVArray = targetMesh.geometry.attributes.uv.array.slice() as Float32Array;
|
||||
|
||||
// Apply new UV mapping (mutates geometry.attributes.uv)
|
||||
reprojectUVs(targetMesh.geometry, bakeRequest.type);
|
||||
|
||||
// Bake
|
||||
try {
|
||||
const dataUrl = bakeTextureToNewUVs(targetMesh, oldUVArray, tex, gl, 1024, bakeRequest.flipX, bakeRequest.flipY);
|
||||
onBakeComplete(dataUrl);
|
||||
} catch (e) {
|
||||
console.error("Baking failed", e);
|
||||
onBakeComplete(sceneObj.materialProps.map); // Fallback to original
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [bakeRequest, meshCache, sceneObjects, gl, onBakeComplete]);
|
||||
|
||||
// Main transform update for the core model
|
||||
useEffect(() => {
|
||||
if (object && sceneObjects) {
|
||||
|
||||
+116
-1
@@ -4,6 +4,9 @@ import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Environment } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { ModelViewer } from './ModelViewer';
|
||||
import { editUVLayer } from '../lib/api/apiUtils';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface Layer {
|
||||
id: string;
|
||||
@@ -45,6 +48,76 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragRef = useRef<{ handle: Handle; startX: number; startY: number; layer: Layer } | null>(null);
|
||||
|
||||
const token = localStorage.getItem('auth_token') || '';
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
const [isEditingLayer, setIsEditingLayer] = useState(false);
|
||||
const [aiPreviewUrl, setAiPreviewUrl] = useState<string | null>(null);
|
||||
const [originalLayerUrl, setOriginalLayerUrl] = useState<string | null>(null);
|
||||
|
||||
const handleAiEdit = async (regenerate = false) => {
|
||||
if (!selectedId || !aiPrompt.trim()) return;
|
||||
const layer = layers.find(l => l.id === selectedId);
|
||||
if (!layer || !layer.img) return;
|
||||
|
||||
setIsEditingLayer(true);
|
||||
try {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = layer.img.width;
|
||||
tempCanvas.height = layer.img.height;
|
||||
const ctx = tempCanvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(layer.img, 0, 0);
|
||||
const base64 = tempCanvas.toDataURL('image/png');
|
||||
|
||||
const fullPrompt = `The user has provided an image layer. Please apply the following edit exactly as requested, while strictly maintaining the EXACT same aspect ratio, resolution, and general structure of the original image.
|
||||
User Prompt: "${aiPrompt}"
|
||||
Negative constraints: Do NOT change the aspect ratio. Do NOT add completely unrelated elements. Do NOT crop the image boundaries.`;
|
||||
|
||||
const newUrl = await editUVLayer(fullPrompt, base64, token || '');
|
||||
|
||||
if (!originalLayerUrl && !regenerate) {
|
||||
setOriginalLayerUrl(layer.url);
|
||||
}
|
||||
|
||||
setAiPreviewUrl(newUrl);
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
setLayers(ls => ls.map(l => l.id === selectedId ? { ...l, url: newUrl, img } : l));
|
||||
setIsEditingLayer(false);
|
||||
};
|
||||
img.src = newUrl;
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert("AI Edit failed: " + e.message);
|
||||
}
|
||||
setIsEditingLayer(false);
|
||||
};
|
||||
|
||||
const handleApproveEdit = () => {
|
||||
setAiPreviewUrl(null);
|
||||
setOriginalLayerUrl(null);
|
||||
setAiPrompt("");
|
||||
};
|
||||
|
||||
const handleRejectEdit = () => {
|
||||
if (selectedId && originalLayerUrl) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
setLayers(ls => ls.map(l => l.id === selectedId ? { ...l, url: originalLayerUrl, img } : l));
|
||||
setAiPreviewUrl(null);
|
||||
setOriginalLayerUrl(null);
|
||||
};
|
||||
img.src = originalLayerUrl;
|
||||
} else {
|
||||
setAiPreviewUrl(null);
|
||||
setOriginalLayerUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const saved = targetObject?.materialProps?.uvLayers;
|
||||
if (saved?.length) {
|
||||
@@ -412,6 +485,48 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje
|
||||
<span className="text-[9px] font-mono text-orange-400 w-6 text-right">{Math.round(selectedLayer[s] * 100)}%</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* AI Layer Edit */}
|
||||
<div className="h-px bg-zinc-700 my-1" />
|
||||
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5"><span className="text-blue-400">✨</span> Edit with AI</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
{isEditingLayer && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-zinc-900/80 backdrop-blur-sm rounded border border-zinc-700 p-2 text-center text-xs text-blue-400">
|
||||
<Loader2 size={18} className="animate-spin mb-1" />
|
||||
Generating...
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
placeholder="e.g. Change the color to red, add a vintage filter..."
|
||||
value={aiPrompt}
|
||||
onChange={e => setAiPrompt(e.target.value)}
|
||||
className="w-full h-16 bg-zinc-900 border border-zinc-700 rounded p-2 text-xs text-zinc-300 placeholder:text-zinc-600 resize-none outline-none focus:border-blue-500"
|
||||
/>
|
||||
{aiPreviewUrl ? (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleApproveEdit} className="flex-1 py-1.5 bg-green-500/20 text-green-400 hover:bg-green-500/30 rounded text-[11px] font-semibold border border-green-500/30 transition-colors">
|
||||
Approve
|
||||
</button>
|
||||
<button onClick={() => handleAiEdit(true)} className="flex-1 py-1.5 bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded text-[11px] font-semibold border border-blue-500/30 transition-colors">
|
||||
Regenerate
|
||||
</button>
|
||||
<button onClick={handleRejectEdit} className="flex-1 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded text-[11px] font-semibold border border-red-500/30 transition-colors">
|
||||
Revert
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleAiEdit(false)}
|
||||
disabled={!aiPrompt.trim()}
|
||||
className="w-full py-1.5 bg-blue-500/10 hover:bg-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed rounded text-[11px] font-semibold text-blue-400 border border-blue-500/30 transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<span className="text-[14px] leading-none">✨</span> Generate Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -487,7 +602,7 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">3D Preview</span>
|
||||
</div>
|
||||
<div className="w-full aspect-square bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800">
|
||||
<Canvas shadows camera={{ position: [2, 2, 2], fov: 45 }}>
|
||||
<Canvas shadows={{ type: THREE.PCFShadowMap }} camera={{ position: [2, 2, 2], fov: 45 }}>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={1} castShadow />
|
||||
<Environment preset="studio" />
|
||||
|
||||
@@ -118,6 +118,71 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
addUser: async (userData) => {
|
||||
const res = await fetch(`${API_URL}/users`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
|
||||
deleteUser: async (id) => {
|
||||
const res = await fetch(`${API_URL}/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
|
||||
triggerPasswordReset: async (id) => {
|
||||
const res = await fetch(`${API_URL}/users/${id}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Auth Extensions
|
||||
activateUser: async (token) => {
|
||||
const res = await fetch(`${API_URL}/auth/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
|
||||
forgotPassword: async (email) => {
|
||||
const res = await fetch(`${API_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
|
||||
resetPassword: async (token, newPassword) => {
|
||||
const res = await fetch(`${API_URL}/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, newPassword })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Credits
|
||||
deductCredit: async () => {
|
||||
const res = await fetch(`${API_URL}/users/deduct-credit`, {
|
||||
|
||||
@@ -79,3 +79,33 @@ export async function pollStatus<T>(
|
||||
|
||||
throw new Error("Polling timeout exceeded.");
|
||||
}
|
||||
|
||||
export async function editUVLayer(prompt: string, imageBase64: string, token: string): Promise<string> {
|
||||
// Extract mime type if present in the data URI
|
||||
let mimeType = 'image/png';
|
||||
if (imageBase64.startsWith('data:')) {
|
||||
const match = imageBase64.match(/^data:([^;]+);/);
|
||||
if (match) {
|
||||
mimeType = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch('/api/sparkplug/edit-layer', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ prompt, imageBase64, mimeType })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
return data.imageUrl;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Calculates new UVs for a geometry based on the chosen projection mapping.
|
||||
* Supported types: 'planar', 'cylinder', 'sphere', 'box'
|
||||
*/
|
||||
export function reprojectUVs(geometry: THREE.BufferGeometry, mappingType: 'planar' | 'cylinder' | 'sphere' | 'box') {
|
||||
if (!geometry.attributes.position) return;
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
const bbox = geometry.boundingBox!;
|
||||
const size = new THREE.Vector3();
|
||||
bbox.getSize(size);
|
||||
const center = new THREE.Vector3();
|
||||
bbox.getCenter(center);
|
||||
|
||||
const posAttr = geometry.attributes.position;
|
||||
const uvArray = new Float32Array(posAttr.count * 2);
|
||||
|
||||
for (let i = 0; i < posAttr.count; i++) {
|
||||
const x = posAttr.getX(i);
|
||||
const y = posAttr.getY(i);
|
||||
const z = posAttr.getZ(i);
|
||||
|
||||
// Normalize coordinates around center, scaled by size
|
||||
const nx = size.x === 0 ? 0.5 : (x - bbox.min.x) / size.x;
|
||||
const ny = size.y === 0 ? 0.5 : (y - bbox.min.y) / size.y;
|
||||
const nz = size.z === 0 ? 0.5 : (z - bbox.min.z) / size.z;
|
||||
|
||||
let u = 0;
|
||||
let v = 0;
|
||||
|
||||
if (mappingType === 'planar') {
|
||||
// Front planar projection
|
||||
u = nx;
|
||||
v = 1.0 - ny;
|
||||
} else if (mappingType === 'cylinder') {
|
||||
// Cylindrical projection
|
||||
const centeredX = x - center.x;
|
||||
const centeredZ = z - center.z;
|
||||
// atan2 returns -PI to PI. We want 0 to 1 around the cylinder.
|
||||
let angle = Math.atan2(centeredZ, centeredX);
|
||||
u = 1.0 - ((angle + Math.PI) / (2 * Math.PI));
|
||||
v = 1.0 - ny;
|
||||
} else if (mappingType === 'sphere') {
|
||||
// Spherical projection
|
||||
const centeredX = x - center.x;
|
||||
const centeredY = y - center.y;
|
||||
const centeredZ = z - center.z;
|
||||
const radius = Math.sqrt(centeredX * centeredX + centeredY * centeredY + centeredZ * centeredZ);
|
||||
|
||||
const phi = Math.acos(centeredY / radius); // 0 to PI
|
||||
let theta = Math.atan2(centeredZ, centeredX); // -PI to PI
|
||||
|
||||
u = 1.0 - ((theta + Math.PI) / (2 * Math.PI));
|
||||
v = phi / Math.PI; // 0 at top, 1 at bottom
|
||||
} else if (mappingType === 'box') {
|
||||
// Simple box projection (pick dominant axis based on normal)
|
||||
// This requires normals
|
||||
let normX = 0, normY = 0, normZ = 1;
|
||||
if (geometry.attributes.normal) {
|
||||
normX = geometry.attributes.normal.getX(i);
|
||||
normY = geometry.attributes.normal.getY(i);
|
||||
normZ = geometry.attributes.normal.getZ(i);
|
||||
}
|
||||
const ax = Math.abs(normX), ay = Math.abs(normY), az = Math.abs(normZ);
|
||||
if (ax > ay && ax > az) {
|
||||
u = nz; v = 1.0 - ny;
|
||||
} else if (ay > ax && ay > az) {
|
||||
u = nx; v = 1.0 - nz;
|
||||
} else {
|
||||
u = nx; v = 1.0 - ny;
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp UVs to avoid edge artifacts
|
||||
u = Math.max(0.001, Math.min(0.999, u));
|
||||
v = Math.max(0.001, Math.min(0.999, v));
|
||||
|
||||
uvArray[i * 2] = u;
|
||||
uvArray[i * 2 + 1] = v;
|
||||
}
|
||||
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(uvArray, 2));
|
||||
geometry.attributes.uv.needsUpdate = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bakes the old texture applied to the old UVs onto a new texture using the new UVs.
|
||||
* We do this by rendering the mesh to a WebGLRenderTarget using a custom shader.
|
||||
* @param mesh The mesh with the new UVs applied to geometry.attributes.uv
|
||||
* @param oldUVArray The Float32Array of the original UVs
|
||||
* @param oldTexture The original texture map
|
||||
* @param renderer The active WebGLRenderer
|
||||
* @param resolution The resolution of the baked texture
|
||||
* @returns A data URL of the baked texture
|
||||
*/
|
||||
export function bakeTextureToNewUVs(
|
||||
mesh: THREE.Mesh,
|
||||
oldUVArray: Float32Array,
|
||||
oldTexture: THREE.Texture,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
resolution = 1024,
|
||||
flipX = false,
|
||||
flipY = false
|
||||
): string {
|
||||
// Store original geometry attributes
|
||||
const geometry = mesh.geometry.clone();
|
||||
|
||||
// Create a secondary UV set for the old UVs
|
||||
geometry.setAttribute('uv2', new THREE.BufferAttribute(oldUVArray, 2));
|
||||
|
||||
// Custom Shader:
|
||||
// Position is derived from the new UVs (to flatten the mesh into the 2D canvas)
|
||||
// Color is sampled from the old texture using the old UVs (uv2)
|
||||
const bakeMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tDiffuse: { value: oldTexture },
|
||||
uFlipX: { value: flipX ? 1.0 : 0.0 },
|
||||
uFlipY: { value: flipY ? 1.0 : 0.0 }
|
||||
},
|
||||
vertexShader: `
|
||||
attribute vec2 uv2;
|
||||
varying vec2 vOldUV;
|
||||
uniform float uFlipX;
|
||||
uniform float uFlipY;
|
||||
void main() {
|
||||
vOldUV = uv2;
|
||||
|
||||
float finalX = mix(uv.x * 2.0 - 1.0, 1.0 - uv.x * 2.0, uFlipX);
|
||||
float finalY = mix(1.0 - uv.y * 2.0, uv.y * 2.0 - 1.0, uFlipY);
|
||||
|
||||
gl_Position = vec4(finalX, finalY, 0.0, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse;
|
||||
varying vec2 vOldUV;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(tDiffuse, vOldUV);
|
||||
}
|
||||
`,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const bakeMesh = new THREE.Mesh(geometry, bakeMaterial);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(bakeMesh);
|
||||
|
||||
// Orthographic camera covering exactly -1 to 1 clip space
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1);
|
||||
|
||||
// Render target for the bake
|
||||
const renderTarget = new THREE.WebGLRenderTarget(resolution, resolution, {
|
||||
format: THREE.RGBAFormat,
|
||||
type: THREE.UnsignedByteType,
|
||||
colorSpace: THREE.SRGBColorSpace,
|
||||
magFilter: THREE.LinearFilter,
|
||||
minFilter: THREE.LinearFilter
|
||||
});
|
||||
|
||||
const originalRenderTarget = renderer.getRenderTarget();
|
||||
const originalClearColor = renderer.getClearColor(new THREE.Color());
|
||||
const originalClearAlpha = renderer.getClearAlpha();
|
||||
|
||||
// Render
|
||||
renderer.setRenderTarget(renderTarget);
|
||||
renderer.setClearColor(0x000000, 0); // Transparent background
|
||||
renderer.clear();
|
||||
renderer.render(scene, camera);
|
||||
|
||||
// Read pixels from render target
|
||||
const pixels = new Uint8Array(resolution * resolution * 4);
|
||||
renderer.readRenderTargetPixels(renderTarget, 0, 0, resolution, resolution, pixels);
|
||||
|
||||
// Restore renderer state
|
||||
renderer.setRenderTarget(originalRenderTarget);
|
||||
renderer.setClearColor(originalClearColor, originalClearAlpha);
|
||||
|
||||
// Flip Y when copying to canvas because readRenderTargetPixels reads bottom-up
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = resolution;
|
||||
canvas.height = resolution;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error("Could not get 2d context");
|
||||
|
||||
const imgData = ctx.createImageData(resolution, resolution);
|
||||
// Manual flip Y
|
||||
for (let y = 0; y < resolution; y++) {
|
||||
for (let x = 0; x < resolution; x++) {
|
||||
const srcIndex = ((resolution - 1 - y) * resolution + x) * 4;
|
||||
const destIndex = (y * resolution + x) * 4;
|
||||
imgData.data[destIndex] = pixels[srcIndex];
|
||||
imgData.data[destIndex + 1] = pixels[srcIndex + 1];
|
||||
imgData.data[destIndex + 2] = pixels[srcIndex + 2];
|
||||
imgData.data[destIndex + 3] = pixels[srcIndex + 3];
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
// Clean up
|
||||
renderTarget.dispose();
|
||||
geometry.dispose();
|
||||
bakeMaterial.dispose();
|
||||
|
||||
// Return base64 JPEG to match sizes
|
||||
return canvas.toDataURL('image/jpeg', 0.85);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
export default function Activate() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setMessage('Invalid or missing activation token.');
|
||||
return;
|
||||
}
|
||||
|
||||
const activateAccount = async () => {
|
||||
try {
|
||||
await api.activateUser(token);
|
||||
setStatus('success');
|
||||
setMessage('Your account has been successfully activated!');
|
||||
} catch (error: any) {
|
||||
setStatus('error');
|
||||
setMessage(error.message || 'Failed to activate account.');
|
||||
}
|
||||
};
|
||||
|
||||
activateAccount();
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center p-4">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 max-w-md w-full text-center shadow-2xl">
|
||||
{status === 'loading' && (
|
||||
<div className="flex flex-col items-center">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">Activating Account...</h2>
|
||||
<p className="text-zinc-400">Please wait while we verify your token.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="flex flex-col items-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">Success!</h2>
|
||||
<p className="text-zinc-400 mb-6">{message}</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="bg-primary hover:bg-primary/90 text-white font-medium py-2 px-6 rounded-xl transition-colors"
|
||||
>
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="flex flex-col items-center">
|
||||
<XCircle className="w-12 h-12 text-red-500 mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">Activation Failed</h2>
|
||||
<p className="text-zinc-400 mb-6">{message}</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="bg-zinc-800 hover:bg-zinc-700 text-white font-medium py-2 px-6 rounded-xl transition-colors"
|
||||
>
|
||||
Return Home
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+135
-13
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ShieldAlert, Loader2, Save } from 'lucide-react';
|
||||
import { ArrowLeft, ShieldAlert, Loader2, Save, Trash2, KeyRound, Plus, X } from 'lucide-react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
interface UserProfile {
|
||||
@@ -18,8 +18,10 @@ export default function Admin() {
|
||||
const { profile } = useAuthStore();
|
||||
const [users, setUsers] = useState<UserProfile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [newEmail, setNewEmail] = useState('');
|
||||
const [newRole, setNewRole] = useState('user');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
if (profile?.role !== 'admin') return;
|
||||
try {
|
||||
@@ -31,6 +33,7 @@ export default function Admin() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [profile]);
|
||||
|
||||
@@ -48,6 +51,41 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this user? All their projects will be permanently deleted as well.")) return;
|
||||
try {
|
||||
await api.deleteUser(id);
|
||||
setUsers(users.filter(u => u.id !== id));
|
||||
alert("User deleted successfully.");
|
||||
} catch (error: any) {
|
||||
alert("Failed to delete user: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (id: string) => {
|
||||
if (!confirm("Send a password reset email to this user?")) return;
|
||||
try {
|
||||
await api.triggerPasswordReset(id);
|
||||
alert("Password reset email sent.");
|
||||
} catch (error: any) {
|
||||
alert("Failed to send reset email: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newEmail) return;
|
||||
try {
|
||||
await api.addUser({ email: newEmail, role: newRole });
|
||||
alert("User created and activation email sent.");
|
||||
setShowAddUser(false);
|
||||
setNewEmail('');
|
||||
fetchUsers();
|
||||
} catch (error: any) {
|
||||
alert("Failed to create user: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (profile?.role !== 'admin') {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center text-white">
|
||||
@@ -62,8 +100,8 @@ export default function Admin() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="min-h-screen bg-zinc-950 text-white p-8 relative">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="flex items-center gap-2 text-zinc-400 hover:text-white mb-8 transition-colors"
|
||||
@@ -71,11 +109,19 @@ export default function Admin() {
|
||||
<ArrowLeft size={16} /> Back to Dashboard
|
||||
</button>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-8">Admin Control Panel</h1>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold">Admin Control Panel</h1>
|
||||
<button
|
||||
onClick={() => setShowAddUser(true)}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-xl transition-colors font-medium"
|
||||
>
|
||||
<Plus size={16} /> Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden">
|
||||
<div className="p-6 border-b border-zinc-800 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">User Limit Management</h2>
|
||||
<h2 className="text-xl font-semibold">User Management</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -96,7 +142,13 @@ export default function Admin() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{users.map(u => (
|
||||
<UserRow key={u.id} user={u} onUpdate={updateLimits} />
|
||||
<UserRow
|
||||
key={u.id}
|
||||
user={u}
|
||||
onUpdate={updateLimits}
|
||||
onDelete={handleDelete}
|
||||
onResetPassword={handleResetPassword}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -104,11 +156,64 @@ export default function Admin() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddUser && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-bold">Add New User</h3>
|
||||
<button onClick={() => setShowAddUser(false)} className="p-2 text-zinc-500 hover:text-white rounded-lg transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddUser} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors"
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Role</label>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={e => setNewRole(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-primary hover:bg-primary/90 text-white font-medium py-3 rounded-xl transition-colors mt-2"
|
||||
>
|
||||
Send Activation Email
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProfile) => void }) => {
|
||||
const UserRow = ({
|
||||
user,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onResetPassword
|
||||
}: {
|
||||
user: UserProfile,
|
||||
onUpdate: (u: UserProfile) => void,
|
||||
onDelete: (id: string) => void,
|
||||
onResetPassword: (id: string) => void
|
||||
}) => {
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [isActive, setIsActive] = useState(user.is_active);
|
||||
const [credits, setCredits] = useState(user.api_credits);
|
||||
@@ -153,7 +258,7 @@ const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProf
|
||||
type="number"
|
||||
value={credits}
|
||||
onChange={e => setCredits(Number(e.target.value))}
|
||||
className="w-24 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary"
|
||||
className="w-20 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
@@ -161,16 +266,33 @@ const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProf
|
||||
type="number"
|
||||
value={storage}
|
||||
onChange={e => setStorage(Number(e.target.value))}
|
||||
className="w-24 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary"
|
||||
className="w-20 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
className="bg-zinc-800 hover:bg-zinc-700 text-white px-3 py-1.5 rounded flex items-center justify-center gap-2 ml-auto transition-colors"
|
||||
title="Save Changes"
|
||||
className="p-1.5 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded transition-colors"
|
||||
>
|
||||
<Save size={14} /> Save
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onResetPassword(user.id)}
|
||||
title="Send Password Reset"
|
||||
className="p-1.5 text-amber-500/70 hover:text-amber-400 bg-amber-500/10 hover:bg-amber-500/20 rounded transition-colors"
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(user.id)}
|
||||
title="Delete User"
|
||||
className="p-1.5 text-red-500/70 hover:text-red-400 bg-red-500/10 hover:bg-red-500/20 rounded transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
+48
-7
@@ -638,6 +638,12 @@ export default function Editor() {
|
||||
} | null>(null);
|
||||
const [exportDialogName, setExportDialogName] = useState('');
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const [rendering, setRendering] = useState(false);
|
||||
const [renderProgress, setRenderProgress] = useState<{ step: string; progress: number } | null>(null);
|
||||
const [isRenderComplete, setIsRenderComplete] = useState(false);
|
||||
const [bakedDataUrl, setBakedDataUrl] = useState<string | null>(null);
|
||||
const [bakeOptions, setBakeOptions] = useState({ flipX: false, flipY: false });
|
||||
const [bakeRequest, setBakeRequest] = useState<{ id: string, type: 'planar' | 'cylinder' | 'sphere' | 'box', flipX: boolean, flipY: boolean, timestamp: number } | null>(null);
|
||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [modelData, setModelData] = useState<{ name: string, data: string, extension: string } | null>(null);
|
||||
@@ -2445,7 +2451,7 @@ export default function Editor() {
|
||||
<div className="flex-1 relative bg-zinc-950">
|
||||
<ErrorBoundary>
|
||||
<Canvas
|
||||
shadows
|
||||
shadows={{ type: THREE.PCFShadowMap }}
|
||||
dpr={[1, 2]}
|
||||
gl={{ preserveDrawingBuffer: true, alpha: true }}
|
||||
performance={{ min: 0.5 }}
|
||||
@@ -2528,7 +2534,22 @@ export default function Editor() {
|
||||
return null;
|
||||
})}
|
||||
|
||||
<ModelViewer ref={modelRef} object={loadedModel} sceneObjects={sceneObjects} />
|
||||
<ModelViewer
|
||||
ref={modelRef}
|
||||
object={loadedModel}
|
||||
sceneObjects={sceneObjects}
|
||||
bakeRequest={bakeRequest}
|
||||
onBakeComplete={(dataUrl) => {
|
||||
if (bakeRequest) {
|
||||
updateObjectMaterial(bakeRequest.id, {
|
||||
map: dataUrl,
|
||||
mappingType: 'uv',
|
||||
bakedMappingType: bakeRequest.type
|
||||
});
|
||||
}
|
||||
setBakeRequest(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{(!isRendering || !renderRequest?.includeAlpha) ? (
|
||||
customHdri ? (
|
||||
@@ -4667,8 +4688,28 @@ export default function Editor() {
|
||||
<option value="planar">Planar</option>
|
||||
<option value="box">Box</option>
|
||||
<option value="cylinder">Cylinder</option>
|
||||
<option value="sphere">Sphere</option>
|
||||
<option value="triplanar">Triplanar</option>
|
||||
</select>
|
||||
{selectedObject.materialProps?.mappingType && selectedObject.materialProps.mappingType !== 'uv' && selectedObject.materialProps.mappingType !== 'triplanar' && (
|
||||
<div className="flex flex-col gap-2 mt-2 bg-zinc-800/50 p-2 rounded border border-zinc-700/50">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedObject.materialProps?.map) return;
|
||||
setBakeRequest({
|
||||
id: selectedObject.id,
|
||||
type: selectedObject.materialProps.mappingType as any,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}}
|
||||
className="w-full py-2 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded shadow transition-colors text-[11px] mt-1"
|
||||
>
|
||||
{bakeRequest?.id === selectedObject.id ? 'Baking...' : 'Bake to UVs (For 2D AI)'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* UV Offset */}
|
||||
@@ -5232,11 +5273,12 @@ export default function Editor() {
|
||||
}
|
||||
});
|
||||
|
||||
// Save model data for scene persistence
|
||||
const modelBlob = await fetch(modelUrl).then(r => r.blob());
|
||||
const blobUrl = URL.createObjectURL(modelBlob);
|
||||
setBlobURLs(prev => [...prev, blobUrl]);
|
||||
setModelData({ name: "Generated_Model.glb", data: blobUrl, extension: 'glb' });
|
||||
setImportStatus("Uploading to cloud storage... 0%");
|
||||
const cloudUrl = await uploadAssetToStorage(modelBlob, 'assets', `models/${user?.id || 'guest'}_${Date.now()}_Generated_Model.glb`, (prog) => {
|
||||
setImportStatus(`Uploading to cloud storage... ${prog}%`);
|
||||
});
|
||||
setModelData({ name: "Generated_Model.glb", data: cloudUrl, extension: 'glb' });
|
||||
|
||||
const boundingBox = new THREE.Box3().setFromObject(object);
|
||||
const center = new THREE.Vector3();
|
||||
@@ -5290,7 +5332,6 @@ export default function Editor() {
|
||||
}
|
||||
|
||||
setLoadedModel(object);
|
||||
setBlobURLs(prev => [...prev, modelUrl]);
|
||||
setSceneObjects(prev => [
|
||||
...prev.filter(obj => obj.type !== 'cube'),
|
||||
...newSceneObjects
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Loader2, KeyRound } from 'lucide-react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center p-4">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 max-w-md w-full text-center shadow-2xl text-white">
|
||||
<h2 className="text-xl font-bold text-red-500 mb-2">Invalid Link</h2>
|
||||
<p className="text-zinc-400 mb-6">The password reset token is missing or invalid.</p>
|
||||
<button onClick={() => navigate('/')} className="bg-zinc-800 hover:bg-zinc-700 text-white font-medium py-2 px-6 rounded-xl transition-colors">
|
||||
Return Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await api.resetPassword(token, password);
|
||||
setSuccess(true);
|
||||
setMessage('Your password has been reset successfully!');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to reset password.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center p-4">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 max-w-md w-full shadow-2xl">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-12 h-12 bg-primary/20 rounded-full flex items-center justify-center text-primary">
|
||||
<KeyRound size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-white text-center mb-2">Reset Password</h2>
|
||||
|
||||
{success ? (
|
||||
<div className="text-center">
|
||||
<p className="text-green-400 mb-6">{message}</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="bg-primary hover:bg-primary/90 text-white font-medium py-2 px-6 rounded-xl transition-colors w-full"
|
||||
>
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleReset} className="space-y-4">
|
||||
<p className="text-zinc-400 text-sm text-center mb-6">Enter your new password below.</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-sm p-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary hover:bg-primary/90 text-white font-medium py-3 rounded-xl transition-colors flex items-center justify-center disabled:opacity-50 mt-2"
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+33
-3
@@ -36,6 +36,9 @@ export default function SparkPlug() {
|
||||
const [modelProgress, setModelProgress] = useState(0);
|
||||
const [generatedGlbUrl, setGeneratedGlbUrl] = useState<string | null>(null);
|
||||
|
||||
// HQ Image Preview
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setPdfFile(e.target.files[0]);
|
||||
@@ -339,11 +342,14 @@ export default function SparkPlug() {
|
||||
{generatedImages.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-4 h-full">
|
||||
{generatedImages.map((img, idx) => (
|
||||
<div key={idx} className="relative bg-zinc-900 rounded-lg overflow-hidden border border-zinc-800 flex items-center justify-center">
|
||||
<img src={img} alt={`View ${idx + 1}`} className="max-w-full max-h-[150px] object-contain" />
|
||||
<div key={idx} className="relative bg-zinc-900 rounded-lg overflow-hidden border border-zinc-800 flex items-center justify-center group cursor-pointer" onClick={() => setPreviewImage(img)}>
|
||||
<img src={img} alt={`View ${idx + 1}`} className="max-w-full max-h-[150px] object-contain transition-transform duration-300 group-hover:scale-110" />
|
||||
<div className="absolute top-2 left-2 bg-black/50 backdrop-blur-sm px-2 py-1 rounded text-[10px] font-bold text-zinc-300 uppercase">
|
||||
{idx === 0 ? 'FRONT' : idx === 1 ? 'BACK' : idx === 2 ? 'LEFT' : 'RIGHT'}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<span className="opacity-0 group-hover:opacity-100 text-white font-bold bg-black/50 px-3 py-1 rounded backdrop-blur-sm transition-opacity">Preview HQ</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -400,7 +406,7 @@ export default function SparkPlug() {
|
||||
) : (
|
||||
<div className="w-full h-[400px] bg-zinc-900 rounded-xl overflow-hidden relative border border-zinc-800 flex flex-col items-center justify-center">
|
||||
{generatedGlbUrl ? (
|
||||
<Canvas shadows dpr={[1, 2]} camera={{ position: [0, 0, 4], fov: 45 }}>
|
||||
<Canvas shadows={{ type: THREE.PCFShadowMap }} dpr={[1, 2]} camera={{ position: [0, 0, 4], fov: 45 }}>
|
||||
<Suspense fallback={null}>
|
||||
<Stage environment="studio" intensity={0.5}>
|
||||
<ModelPreview url={generatedGlbUrl} />
|
||||
@@ -428,6 +434,30 @@ export default function SparkPlug() {
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* HQ Image Preview Modal */}
|
||||
{previewImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-8 cursor-zoom-out"
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-6 right-6 text-white hover:text-red-500 transition-colors z-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewImage(null);
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="HQ Preview"
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl cursor-default"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ interface AuthState {
|
||||
signOut: () => void;
|
||||
setProfile: (profile: UserProfile) => void;
|
||||
isAuthModalOpen: boolean;
|
||||
openAuthModal: (mode?: 'login' | 'register') => void;
|
||||
openAuthModal: (mode?: 'login' | 'register' | 'forgot-password') => void;
|
||||
closeAuthModal: () => void;
|
||||
authMode: 'login' | 'register';
|
||||
authMode: 'login' | 'register' | 'forgot-password';
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
Reference in New Issue
Block a user