Deploy to production
This commit is contained in:
@@ -6,3 +6,6 @@ coverage/
|
|||||||
*.log
|
*.log
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
backend/node_modules/
|
||||||
|
backend/data/
|
||||||
|
backend/uploads/
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ RUN npm run build
|
|||||||
FROM nginx:alpine AS production
|
FROM nginx:alpine AS production
|
||||||
# Copy built assets from the build stage to Nginx
|
# Copy built assets from the build stage to Nginx
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
# Expose port 80 for Nginx
|
# Expose port 80 for Nginx
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
# Start Nginx
|
# Start Nginx
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="refresh" content="0;url='https://deploy.digifox.live/login'" />
|
||||||
|
|
||||||
|
<title>Redirecting to https://deploy.digifox.live/login</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
Redirecting to <a href="https://deploy.digifox.live/login">https://deploy.digifox.live/login</a>.
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3005
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, 'data', 'database.sqlite');
|
||||||
|
const dataDir = path.dirname(dbPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(dbPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error opening database', err.message);
|
||||||
|
} else {
|
||||||
|
console.log('Connected to the SQLite database.');
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
password_hash TEXT,
|
||||||
|
role TEXT DEFAULT 'user',
|
||||||
|
api_credits INTEGER DEFAULT 10,
|
||||||
|
storage_limit_mb INTEGER DEFAULT 500,
|
||||||
|
is_active INTEGER DEFAULT 0
|
||||||
|
)`, (err) => {
|
||||||
|
// Attempt to add column for existing databases (fails silently if exists)
|
||||||
|
if (!err) {
|
||||||
|
db.run('ALTER TABLE profiles ADD COLUMN is_active INTEGER DEFAULT 0', () => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
name TEXT,
|
||||||
|
scene_data TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES profiles (id)
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = (sql, params = []) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve({ id: this.lastID, changes: this.changes });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const get = (sql, params = []) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (err, result) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const all = (sql, params = []) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { db, run, get, all };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "titan3d-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Local API for Titan3d",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const { db, run, get, all } = require('./database');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '100mb' }));
|
||||||
|
|
||||||
|
const JWT_SECRET = 'viz-3d-local-secret-key-1234';
|
||||||
|
|
||||||
|
// Ensure uploads dir exists
|
||||||
|
const uploadsDir = path.join(__dirname, 'uploads');
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use('/uploads', express.static(uploadsDir));
|
||||||
|
|
||||||
|
// Multer storage
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: function (req, file, cb) {
|
||||||
|
cb(null, uploadsDir);
|
||||||
|
},
|
||||||
|
filename: function (req, file, cb) {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
cb(null, uniqueSuffix + '-' + file.originalname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const upload = multer({ storage: storage });
|
||||||
|
|
||||||
|
// --- AUTH MIDDLEWARE ---
|
||||||
|
const authenticate = (req, res, next) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) return res.status(401).json({ error: 'No token provided' });
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||||
|
if (err) return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- ROUTES ---
|
||||||
|
|
||||||
|
// 1. Auth Register
|
||||||
|
app.post('/api/auth/register', async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
try {
|
||||||
|
const existing = await get('SELECT * FROM profiles WHERE email = ?', [email]);
|
||||||
|
if (existing) return res.status(400).json({ error: 'User already exists' });
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
const id = uuidv4();
|
||||||
|
// First user becomes admin and is automatically active
|
||||||
|
const countRow = await get('SELECT COUNT(*) as count FROM profiles');
|
||||||
|
const role = countRow.count === 0 ? 'admin' : 'user';
|
||||||
|
const is_active = countRow.count === 0 ? 1 : 0;
|
||||||
|
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (is_active === 0) {
|
||||||
|
return res.json({ status: 'pending', message: 'Account created. Please contact an administrator to activate your account.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign({ id, email, role }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [id]);
|
||||||
|
res.json({ status: 'active', token, profile });
|
||||||
|
} 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;
|
||||||
|
try {
|
||||||
|
const user = await get('SELECT * FROM profiles WHERE email = ?', [email]);
|
||||||
|
if (!user) return res.status(400).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!valid) return res.status(400).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
|
if (user.is_active === 0) {
|
||||||
|
return res.status(403).json({ error: 'Account pending admin approval. Please contact an administrator.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign({ id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
const profile = { id: user.id, email: user.email, role: user.role, api_credits: user.api_credits, storage_limit_mb: user.storage_limit_mb, is_active: user.is_active };
|
||||||
|
res.json({ token, profile });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Auth Me
|
||||||
|
app.get('/api/auth/me', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [req.user.id]);
|
||||||
|
res.json({ profile });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Get all projects for user
|
||||||
|
app.get('/api/projects', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const projects = await all('SELECT id, name FROM projects WHERE user_id = ?', [req.user.id]);
|
||||||
|
res.json({ projects });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Get project by ID
|
||||||
|
app.get('/api/projects/:id', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = await get('SELECT * FROM projects WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
if (project.scene_data) {
|
||||||
|
project.scene_data = JSON.parse(project.scene_data);
|
||||||
|
}
|
||||||
|
res.json({ project });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Save (create or update) project
|
||||||
|
app.post('/api/projects', authenticate, async (req, res) => {
|
||||||
|
const { id, name, scene_data } = req.body;
|
||||||
|
const projectId = id || uuidv4();
|
||||||
|
try {
|
||||||
|
const existing = await get('SELECT * FROM projects WHERE id = ?', [projectId]);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
await run('UPDATE projects SET name = ?, scene_data = ? WHERE id = ?', [name, JSON.stringify(scene_data), projectId]);
|
||||||
|
} else {
|
||||||
|
await run('INSERT INTO projects (id, user_id, name, scene_data) VALUES (?, ?, ?, ?)', [projectId, req.user.id, name, JSON.stringify(scene_data)]);
|
||||||
|
}
|
||||||
|
res.json({ success: true, id: projectId });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Get users (Admin only)
|
||||||
|
app.get('/api/users', authenticate, async (req, res) => {
|
||||||
|
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
try {
|
||||||
|
const users = await all('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles');
|
||||||
|
res.json({ users });
|
||||||
|
} 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' });
|
||||||
|
const { role, api_credits, storage_limit_mb, is_active } = req.body;
|
||||||
|
try {
|
||||||
|
await run('UPDATE profiles SET role = ?, api_credits = ?, storage_limit_mb = ?, is_active = ? WHERE id = ?', [role, api_credits, storage_limit_mb, is_active !== undefined ? is_active : 1, req.params.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 9. Deduct API credit
|
||||||
|
app.post('/api/users/deduct-credit', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]);
|
||||||
|
if (user.api_credits <= 0) return res.status(400).json({ error: 'Not enough credits' });
|
||||||
|
await run('UPDATE profiles SET api_credits = api_credits - 1 WHERE id = ?', [req.user.id]);
|
||||||
|
const updated = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]);
|
||||||
|
res.json({ api_credits: updated.api_credits });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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' });
|
||||||
|
const url = `${req.protocol}://${req.get('host')}/uploads/${req.file.filename}`;
|
||||||
|
res.json({ url });
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3005;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Backend server running on port ${PORT}`);
|
||||||
|
});
|
||||||
+21
-11
@@ -3,16 +3,26 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: development
|
target: production
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3001:80"
|
||||||
volumes:
|
|
||||||
# Map the host source code to the container for hot-reloading
|
|
||||||
- .:/app
|
|
||||||
# Prevents the container's node_modules from being overwritten by the host
|
|
||||||
- /app/node_modules
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=production
|
||||||
# Optional: ensure container runs smoothly on Windows host
|
labels:
|
||||||
stdin_open: true
|
- "coolify.endpoint=true"
|
||||||
tty: true
|
|
||||||
|
api:
|
||||||
|
restart: always
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
ports:
|
||||||
|
- "3005:3005"
|
||||||
|
volumes:
|
||||||
|
- data-volume:/app/data
|
||||||
|
- uploads-volume:/app/uploads
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data-volume:
|
||||||
|
uploads-volume:
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Serve static assets
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:3005;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy /uploads to backend
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://api:3005;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+58
@@ -25,6 +25,7 @@
|
|||||||
"postprocessing": "^6.39.0",
|
"postprocessing": "^6.39.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
"three-stdlib": "^2.36.1",
|
"three-stdlib": "^2.36.1",
|
||||||
@@ -3827,6 +3828,57 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router/node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-use-measure": {
|
"node_modules/react-use-measure": {
|
||||||
"version": "2.1.7",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||||
@@ -4015,6 +4067,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "viz-3D_hun",
|
"name": "titan3d",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"postprocessing": "^6.39.0",
|
"postprocessing": "^6.39.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"three": "^0.183.2",
|
"three": "^0.183.2",
|
||||||
"three-stdlib": "^2.36.1",
|
"three-stdlib": "^2.36.1",
|
||||||
|
|||||||
+36
-4867
File diff suppressed because it is too large
Load Diff
@@ -121,7 +121,7 @@ export const ApiSettingsModal: React.FC<ApiSettingsModalProps> = ({ isOpen, onCl
|
|||||||
{/* Viz AI (Image-to-Image & LLM) */}
|
{/* Viz AI (Image-to-Image & LLM) */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h3 className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
|
<h3 className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
|
||||||
<Sparkles size={14} /> Viz AI & Mockup Generation
|
<Sparkles size={14} /> Titan3d AI & Mockup Generation
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 pl-2 border-l-2 border-zinc-800">
|
<div className="grid grid-cols-1 gap-4 pl-2 border-l-2 border-zinc-800">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||||||
import { X, Upload, Settings as SettingsIcon, Image as ImageIcon, Box, Download, AlertCircle, Loader2, Check } from 'lucide-react';
|
import { X, Upload, Settings as SettingsIcon, Image as ImageIcon, Box, Download, AlertCircle, Loader2, Check } from 'lucide-react';
|
||||||
import { fal } from '@fal-ai/client';
|
import { fal } from '@fal-ai/client';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { api } from '../lib/api';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas } from '@react-three/fiber';
|
||||||
import { OrbitControls, Stage } from '@react-three/drei';
|
import { OrbitControls, Stage } from '@react-three/drei';
|
||||||
import { GLTFLoader } from 'three-stdlib';
|
import { GLTFLoader } from 'three-stdlib';
|
||||||
@@ -44,6 +46,7 @@ const ModelPreview = ({ url }: { url: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose, onPushToApp }) => {
|
export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose, onPushToApp }) => {
|
||||||
|
const { profile, setProfile } = useAuthStore();
|
||||||
const [apiProvider, setApiProvider] = useState<'fal' | 'tripo' | 'meshy'>('fal');
|
const [apiProvider, setApiProvider] = useState<'fal' | 'tripo' | 'meshy'>('fal');
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [tripoApiKey, setTripoApiKey] = useState('');
|
const [tripoApiKey, setTripoApiKey] = useState('');
|
||||||
@@ -146,6 +149,10 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
|||||||
setError("Please upload an image first.");
|
setError("Please upload an image first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!profile || profile.api_credits <= 0) {
|
||||||
|
setError("Not enough AI Credits. Please contact an administrator.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (apiProvider === 'fal' && !apiKey) {
|
if (apiProvider === 'fal' && !apiKey) {
|
||||||
setError("Fal API Key is required. Please set it in Tools > API Settings.");
|
setError("Fal API Key is required. Please set it in Tools > API Settings.");
|
||||||
return;
|
return;
|
||||||
@@ -159,6 +166,17 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deductCredit = async () => {
|
||||||
|
if (profile) {
|
||||||
|
try {
|
||||||
|
const { api_credits } = await api.deductCredit();
|
||||||
|
setProfile({ ...profile, api_credits });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Credit deduction failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -202,6 +220,7 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
|||||||
if (modelUrl) {
|
if (modelUrl) {
|
||||||
setGeneratedModelUrl(modelUrl);
|
setGeneratedModelUrl(modelUrl);
|
||||||
setProgressMessage("Done!");
|
setProgressMessage("Done!");
|
||||||
|
deductCredit();
|
||||||
} else {
|
} else {
|
||||||
setError("Failed to extract 3D model from response.");
|
setError("Failed to extract 3D model from response.");
|
||||||
}
|
}
|
||||||
@@ -268,6 +287,7 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
|||||||
setGeneratedModelUrl(modelUrl);
|
setGeneratedModelUrl(modelUrl);
|
||||||
setProgressMessage("Done!");
|
setProgressMessage("Done!");
|
||||||
isDone = true;
|
isDone = true;
|
||||||
|
deductCredit();
|
||||||
} else if (status === 'failed' || status === 'cancelled') {
|
} else if (status === 'failed' || status === 'cancelled') {
|
||||||
throw new Error(`Tripo API Task ${status}`);
|
throw new Error(`Tripo API Task ${status}`);
|
||||||
}
|
}
|
||||||
@@ -339,6 +359,7 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
|||||||
setGeneratedModelUrl(modelUrl);
|
setGeneratedModelUrl(modelUrl);
|
||||||
setProgressMessage("Done!");
|
setProgressMessage("Done!");
|
||||||
isDone = true;
|
isDone = true;
|
||||||
|
deductCredit();
|
||||||
} else if (status === 'FAILED' || status === 'EXPIRED') {
|
} else if (status === 'FAILED' || status === 'EXPIRED') {
|
||||||
throw new Error(`Meshy API Task ${status}`);
|
throw new Error(`Meshy API Task ${status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
allowedRoles?: ('user' | 'admin')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtectedRoute = ({ allowedRoles = ['user', 'admin'] }: ProtectedRouteProps) => {
|
||||||
|
const { user, profile } = useAuthStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Redirect to login but save the attempted url
|
||||||
|
return <Navigate to="/auth" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile && !allowedRoles.includes(profile.role)) {
|
||||||
|
// Role not authorized, go to dashboard
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
@@ -83,7 +83,7 @@ export const RenderModal: React.FC<RenderModalProps> = ({ isOpen, onClose, onRen
|
|||||||
<div className="flex flex-col gap-8 max-w-2xl">
|
<div className="flex flex-col gap-8 max-w-2xl">
|
||||||
{/* Mode Tabs */}
|
{/* Mode Tabs */}
|
||||||
<div className="flex p-1 bg-zinc-950 rounded-lg border border-zinc-800 self-start">
|
<div className="flex p-1 bg-zinc-950 rounded-lg border border-zinc-800 self-start">
|
||||||
{['Still Image', 'Animation', 'VIZ3D XR', 'Configurator', 'CMF'].map((mode) => (
|
{['Still Image', 'Animation', 'Titan3d XR', 'Configurator', 'CMF'].map((mode) => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Sparkles, Image as ImageIcon, Loader2, Download, Settings, Wand2, Lightbulb } from 'lucide-react';
|
import { X, Sparkles, Image as ImageIcon, Loader2, Download, Settings, Wand2, Lightbulb } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
interface VizAiModalProps {
|
interface VizAiModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -22,6 +24,8 @@ const dataURLtoBlob = (dataurl: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const VizAiModal: React.FC<VizAiModalProps> = ({ isOpen, onClose, baseImage }) => {
|
export const VizAiModal: React.FC<VizAiModalProps> = ({ isOpen, onClose, baseImage }) => {
|
||||||
|
const { profile, setProfile } = useAuthStore();
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
const [stabilityApiKey, setStabilityApiKey] = useState('');
|
const [stabilityApiKey, setStabilityApiKey] = useState('');
|
||||||
const [openAiKey, setOpenAiKey] = useState('');
|
const [openAiKey, setOpenAiKey] = useState('');
|
||||||
@@ -108,6 +112,11 @@ export const VizAiModal: React.FC<VizAiModalProps> = ({ isOpen, onClose, baseIma
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!profile || profile.api_credits <= 0) {
|
||||||
|
setError("Not enough AI Credits. Please contact an administrator.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsGeneratingImage(true);
|
setIsGeneratingImage(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -137,6 +146,13 @@ export const VizAiModal: React.FC<VizAiModalProps> = ({ isOpen, onClose, baseIma
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const imageUrl = URL.createObjectURL(blob);
|
const imageUrl = URL.createObjectURL(blob);
|
||||||
setGeneratedImage(imageUrl);
|
setGeneratedImage(imageUrl);
|
||||||
|
|
||||||
|
// Deduct credit
|
||||||
|
if (profile) {
|
||||||
|
const { api_credits } = await api.deductCredit();
|
||||||
|
setProfile({ ...profile, api_credits });
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setError(err.message || "Failed to generate image.");
|
setError(err.message || "Failed to generate image.");
|
||||||
@@ -163,7 +179,7 @@ export const VizAiModal: React.FC<VizAiModalProps> = ({ isOpen, onClose, baseIma
|
|||||||
<Sparkles size={16} className="text-white" />
|
<Sparkles size={16} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold text-zinc-100">VIZ AI Generator</h2>
|
<h2 className="text-sm font-bold text-zinc-100">Titan3d AI Generator</h2>
|
||||||
<p className="text-[10px] text-zinc-400">Generate stunning product mockups directly from your 3D scene</p>
|
<p className="text-[10px] text-zinc-400">Generate stunning product mockups directly from your 3D scene</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3005/api';
|
||||||
|
|
||||||
|
const getHeaders = () => {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
register: async (email, password) => {
|
||||||
|
const res = await fetch(`${API_URL}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data; // returns { status: 'pending' | 'active', token, profile }
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (email, password) => {
|
||||||
|
const res = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMe: async () => {
|
||||||
|
const res = await fetch(`${API_URL}/auth/me`, { headers: getHeaders() });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
getProjects: async () => {
|
||||||
|
const res = await fetch(`${API_URL}/projects`, { headers: getHeaders() });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProject: async (id) => {
|
||||||
|
const res = await fetch(`${API_URL}/projects/${id}`, { headers: getHeaders() });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveProject: async (projectData) => {
|
||||||
|
const res = await fetch(`${API_URL}/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(projectData)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
getUsers: async () => {
|
||||||
|
const res = await fetch(`${API_URL}/users`, { headers: getHeaders() });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: async (id, updateData) => {
|
||||||
|
const res = await fetch(`${API_URL}/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(updateData)
|
||||||
|
});
|
||||||
|
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`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
uploadAsset: async (fileBlob) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fileBlob);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for API requests with exponential backoff and polling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export interface FetchOptions extends RequestInit {
|
||||||
|
maxRetries?: number;
|
||||||
|
baseDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithRetry(url: string, options: FetchOptions = {}): Promise<Response> {
|
||||||
|
const { maxRetries = 3, baseDelayMs = 1000, ...fetchOptions } = options;
|
||||||
|
let retries = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
if (response.ok) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't retry on 4xx errors (client errors)
|
||||||
|
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
||||||
|
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a 429 (Rate Limit) or 5xx, we throw to trigger a retry
|
||||||
|
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (retries >= maxRetries) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
retries++;
|
||||||
|
// Exponential backoff with some jitter
|
||||||
|
const waitTime = (baseDelayMs * Math.pow(2, retries - 1)) + (Math.random() * 500);
|
||||||
|
console.warn(`Request failed, retrying in ${Math.round(waitTime)}ms... (Attempt ${retries}/${maxRetries})`);
|
||||||
|
await delay(waitTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollOptions {
|
||||||
|
intervalMs?: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollStatus<T>(
|
||||||
|
pollFn: () => Promise<{ status: 'processing' | 'success' | 'failed', data?: T, progress?: number }>,
|
||||||
|
options: PollOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { intervalMs = 2000, maxAttempts = 150, onProgress } = options; // Default to 5 minutes total polling
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
try {
|
||||||
|
const result = await pollFn();
|
||||||
|
|
||||||
|
if (result.status === 'success' && result.data) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
throw new Error("Task failed during processing on the remote server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.progress !== undefined && onProgress) {
|
||||||
|
onProgress(result.progress);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If the poll request itself fails, we log it but keep trying until maxAttempts
|
||||||
|
console.warn("Polling request failed, will retry next interval:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts++;
|
||||||
|
await delay(intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Polling timeout exceeded.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { fetchWithRetry, pollStatus } from './apiUtils';
|
||||||
|
|
||||||
|
const MESHY_BASE_URL = 'https://api.meshy.ai/openapi/v1';
|
||||||
|
|
||||||
|
export interface MeshyTaskResponse {
|
||||||
|
result: string;
|
||||||
|
progress: number;
|
||||||
|
status: 'PENDING' | 'IN_PROGRESS' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED';
|
||||||
|
task_error?: { message: string };
|
||||||
|
model_urls?: { glb: string; usdz?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMeshyTask(apiKey: string, imageBase64DataUrl: string): Promise<string> {
|
||||||
|
const response = await fetchWithRetry(`${MESHY_BASE_URL}/image-to-3d`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
image_url: imageBase64DataUrl,
|
||||||
|
enable_pbr: true,
|
||||||
|
should_remesh: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.result; // Task ID
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForMeshyTask(apiKey: string, taskId: string, onProgress?: (p: number) => void): Promise<string> {
|
||||||
|
return pollStatus(async () => {
|
||||||
|
const response = await fetchWithRetry(`${MESHY_BASE_URL}/image-to-3d/${taskId}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||||
|
maxRetries: 2,
|
||||||
|
baseDelayMs: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: MeshyTaskResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'SUCCEEDED' && data.model_urls?.glb) {
|
||||||
|
return { status: 'success', data: data.model_urls.glb };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'FAILED' || data.status === 'EXPIRED') {
|
||||||
|
console.error("Meshy task failed:", data.task_error);
|
||||||
|
return { status: 'failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'processing', progress: data.progress || 0 };
|
||||||
|
}, {
|
||||||
|
intervalMs: 3000,
|
||||||
|
maxAttempts: 100, // 5 minutes max wait
|
||||||
|
onProgress
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { fetchWithRetry, pollStatus } from './apiUtils';
|
||||||
|
|
||||||
|
const TRIPO_BASE_URL = 'https://api.tripo3d.ai/v2/openapi';
|
||||||
|
|
||||||
|
export interface TripoTaskResponse {
|
||||||
|
code: number;
|
||||||
|
data: {
|
||||||
|
task_id: string;
|
||||||
|
status: 'queued' | 'running' | 'success' | 'failed' | 'cancelled';
|
||||||
|
progress: number;
|
||||||
|
output?: {
|
||||||
|
model?: string; // URL to the model
|
||||||
|
base_model?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Upload Image
|
||||||
|
export async function uploadImageToTripo(apiKey: string, imageFile: File): Promise<string> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', imageFile);
|
||||||
|
|
||||||
|
const response = await fetchWithRetry(`${TRIPO_BASE_URL}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== 0 || !data.data.image_token) {
|
||||||
|
throw new Error("Failed to upload image to Tripo");
|
||||||
|
}
|
||||||
|
return data.data.image_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create Task
|
||||||
|
export async function createTripoTask(apiKey: string, imageToken: string): Promise<string> {
|
||||||
|
const response = await fetchWithRetry(`${TRIPO_BASE_URL}/task`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'image_to_model',
|
||||||
|
file: { type: 'jpg', file_token: imageToken }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== 0 || !data.data.task_id) {
|
||||||
|
throw new Error("Failed to create Tripo task");
|
||||||
|
}
|
||||||
|
return data.data.task_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Poll Task
|
||||||
|
export async function waitForTripoTask(apiKey: string, taskId: string, onProgress?: (p: number) => void): Promise<string> {
|
||||||
|
return pollStatus(async () => {
|
||||||
|
const response = await fetchWithRetry(`${TRIPO_BASE_URL}/task/${taskId}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||||
|
maxRetries: 2,
|
||||||
|
baseDelayMs: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
const json: TripoTaskResponse = await response.json();
|
||||||
|
const data = json.data;
|
||||||
|
|
||||||
|
if (data.status === 'success' && data.output?.model) {
|
||||||
|
return { status: 'success', data: data.output.model };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'failed' || data.status === 'cancelled') {
|
||||||
|
return { status: 'failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'processing', progress: data.progress || 0 };
|
||||||
|
}, {
|
||||||
|
intervalMs: 3000,
|
||||||
|
maxAttempts: 100,
|
||||||
|
onProgress
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export const uploadAssetToStorage = async (file: Blob | File, bucket: string, path: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const url = await api.uploadAsset(file);
|
||||||
|
return url;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Storage upload exception, falling back to data URL.", err);
|
||||||
|
return await fileToDataUrl(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fileToDataUrl = (file: Blob | File): Promise<string> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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 { api } from '../lib/api';
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
api_credits: number;
|
||||||
|
storage_limit_mb: number;
|
||||||
|
is_active: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { profile } = useAuthStore();
|
||||||
|
const [users, setUsers] = useState<UserProfile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
if (profile?.role !== 'admin') return;
|
||||||
|
try {
|
||||||
|
const data = await api.getUsers();
|
||||||
|
setUsers(data.users || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUsers();
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
const updateLimits = async (userObj: UserProfile) => {
|
||||||
|
try {
|
||||||
|
await api.updateUser(userObj.id, {
|
||||||
|
role: userObj.role,
|
||||||
|
api_credits: userObj.api_credits,
|
||||||
|
storage_limit_mb: userObj.storage_limit_mb,
|
||||||
|
is_active: userObj.is_active
|
||||||
|
});
|
||||||
|
alert("Updated successfully!");
|
||||||
|
} catch (error: any) {
|
||||||
|
alert("Failed to update 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">
|
||||||
|
<ShieldAlert size={48} className="text-red-500 mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Access Denied</h1>
|
||||||
|
<p className="text-zinc-400 mb-6">You do not have administrative privileges.</p>
|
||||||
|
<button onClick={() => navigate('/dashboard')} className="px-6 py-2 bg-zinc-800 rounded-lg hover:bg-zinc-700">
|
||||||
|
Return to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 text-white p-8">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
className="flex items-center gap-2 text-zinc-400 hover:text-white mb-8 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Dashboard
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold mb-8">Admin Control Panel</h1>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 flex justify-center"><Loader2 className="animate-spin" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-zinc-950 text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4 font-medium">User ID</th>
|
||||||
|
<th className="p-4 font-medium">Role</th>
|
||||||
|
<th className="p-4 font-medium">Status</th>
|
||||||
|
<th className="p-4 font-medium">API Credits</th>
|
||||||
|
<th className="p-4 font-medium">Storage Limit (MB)</th>
|
||||||
|
<th className="p-4 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800">
|
||||||
|
{users.map(u => (
|
||||||
|
<UserRow key={u.id} user={u} onUpdate={updateLimits} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProfile) => void }) => {
|
||||||
|
const [role, setRole] = useState(user.role);
|
||||||
|
const [isActive, setIsActive] = useState(user.is_active);
|
||||||
|
const [credits, setCredits] = useState(user.api_credits);
|
||||||
|
const [storage, setStorage] = useState(user.storage_limit_mb);
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
onUpdate({
|
||||||
|
...user,
|
||||||
|
role,
|
||||||
|
is_active: isActive,
|
||||||
|
api_credits: credits,
|
||||||
|
storage_limit_mb: storage
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="hover:bg-zinc-800/50">
|
||||||
|
<td className="p-4 font-mono text-xs">{user.id}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value)}
|
||||||
|
className={`px-2 py-1 rounded text-xs focus:outline-none focus:border-primary border border-transparent ${role === 'admin' ? 'bg-red-500/20 text-red-400' : 'bg-blue-500/20 text-blue-400'}`}
|
||||||
|
>
|
||||||
|
<option value="user" className="bg-zinc-900 text-white">User</option>
|
||||||
|
<option value="admin" className="bg-zinc-900 text-white">Admin</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<select
|
||||||
|
value={isActive}
|
||||||
|
onChange={(e) => setIsActive(Number(e.target.value))}
|
||||||
|
className={`px-2 py-1 rounded text-xs focus:outline-none focus:border-primary border border-transparent ${isActive === 1 ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}
|
||||||
|
>
|
||||||
|
<option value={1} className="bg-zinc-900 text-white">Active</option>
|
||||||
|
<option value={0} className="bg-zinc-900 text-white">Pending</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-right">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Save size={14} /> Save
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSearchParams, useNavigate, useLocation, Link } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { Box, Mail, Lock, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Auth() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { setSession } = useAuthStore();
|
||||||
|
|
||||||
|
const isRegister = searchParams.get('mode') === 'register';
|
||||||
|
const from = location.state?.from?.pathname || '/dashboard';
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
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.');
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
} else {
|
||||||
|
setSession(res.token, res.profile);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { token, profile } = await api.login(email, password);
|
||||||
|
setSession(token, profile);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'An error occurred during authentication.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-2xl p-8 shadow-2xl">
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
|
||||||
|
<Box size={24} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-white text-center mb-2">
|
||||||
|
{isRegister ? 'Create an Account' : 'Welcome Back'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 text-sm text-center mb-8">
|
||||||
|
{isRegister ? 'Sign up to start building 3D scenes.' : 'Sign in to access your projects.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 rounded-lg text-sm mb-6 text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="bg-green-500/10 border border-green-500/20 text-green-400 p-3 rounded-lg text-sm mb-6 text-center">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email address"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl py-3 pl-10 pr-4 text-white focus:outline-none focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl py-3 pl-10 pr-4 text-white focus:outline-none focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 size={18} className="animate-spin" />}
|
||||||
|
{isRegister ? 'Sign Up' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center text-sm text-zinc-500">
|
||||||
|
{isRegister ? (
|
||||||
|
<p>Already have an account? <Link to="/auth" className="text-primary hover:underline">Sign in</Link></p>
|
||||||
|
) : (
|
||||||
|
<p>Don't have an account? <Link to="/auth?mode=register" className="text-primary hover:underline">Sign up</Link></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { LogOut, Plus, Folder, Loader2 } from 'lucide-react';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { user, profile, signOut } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const data = await api.getProjects();
|
||||||
|
setProjects(data.projects || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProjects();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await signOut();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewProject = () => {
|
||||||
|
navigate('/editor/new');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 text-white flex flex-col">
|
||||||
|
<header className="h-16 border-b border-zinc-800 bg-zinc-900 flex items-center justify-between px-8">
|
||||||
|
<div className="font-bold text-lg tracking-tight">Titan<span className="text-primary">3d</span> / Dashboard</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{profile?.role === 'admin' && (
|
||||||
|
<Link to="/admin" className="text-sm text-primary hover:underline font-medium">Admin Panel</Link>
|
||||||
|
)}
|
||||||
|
<div className="text-sm text-zinc-400">{user?.email}</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
title="Sign Out"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 p-8 max-w-6xl mx-auto w-full">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||||
|
<h3 className="text-zinc-400 text-sm font-medium mb-2">AI Credits</h3>
|
||||||
|
<div className="text-3xl font-bold text-white">{profile?.api_credits ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||||
|
<h3 className="text-zinc-400 text-sm font-medium mb-2">Storage Usage</h3>
|
||||||
|
<div className="text-3xl font-bold text-white">0 / {profile?.storage_limit_mb ?? 100} MB</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
|
||||||
|
<h3 className="text-zinc-400 text-sm font-medium mb-2">Total Projects</h3>
|
||||||
|
<div className="text-3xl font-bold text-white">{projects.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-2xl font-bold">Your Projects</h2>
|
||||||
|
<button
|
||||||
|
onClick={createNewProject}
|
||||||
|
className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-xl transition-colors font-medium text-sm"
|
||||||
|
>
|
||||||
|
<Plus size={16} /> New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center p-12">
|
||||||
|
<Loader2 size={32} className="animate-spin text-zinc-500" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="aspect-video bg-zinc-900 border border-zinc-800 border-dashed rounded-2xl flex flex-col items-center justify-center text-zinc-500 hover:text-white hover:border-zinc-700 transition-colors cursor-pointer group" onClick={createNewProject}>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-zinc-800 group-hover:bg-zinc-700 flex items-center justify-center mb-2 transition-colors">
|
||||||
|
<Plus size={20} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">Create Blank Scene</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.map(project => (
|
||||||
|
<div key={project.id} onClick={() => navigate(`/editor/${project.id}`)} className="aspect-video bg-zinc-900 border border-zinc-800 rounded-2xl p-4 flex flex-col cursor-pointer hover:border-zinc-700 transition-colors group relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<Folder size={48} className="text-zinc-800 group-hover:text-zinc-700 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white truncate">{project.name}</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">{new Date(project.created_at).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { Box } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 text-white flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-primary/20 flex items-center justify-center mb-8 shadow-[0_0_50px_rgba(var(--primary-rgb),0.3)]">
|
||||||
|
<Box size={32} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-bold tracking-tight mb-4 text-center">
|
||||||
|
Welcome to Titan<span className="text-primary">3d</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-400 text-lg mb-10 max-w-lg text-center">
|
||||||
|
The ultimate cloud-based 3D product configurator. Build, edit, and visualize 3D assets entirely in your browser with the power of AI.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="px-8 py-3 bg-primary hover:bg-primary/90 text-white font-semibold rounded-xl transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Link
|
||||||
|
to="/auth"
|
||||||
|
className="px-8 py-3 bg-primary hover:bg-primary/90 text-white font-semibold rounded-xl transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/auth?mode=register"
|
||||||
|
className="px-8 py-3 bg-zinc-800 hover:bg-zinc-700 text-white font-semibold rounded-xl transition-all border border-zinc-700"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
api_credits: number;
|
||||||
|
storage_limit_mb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: { id: string, email: string } | null;
|
||||||
|
profile: UserProfile | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
initialize: () => Promise<void>;
|
||||||
|
setSession: (token: string, profile: UserProfile) => void;
|
||||||
|
signOut: () => void;
|
||||||
|
setProfile: (profile: UserProfile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
profile: null,
|
||||||
|
isLoading: true,
|
||||||
|
|
||||||
|
initialize: async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) {
|
||||||
|
set({ user: null, profile: null, isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { profile } = await api.getMe();
|
||||||
|
if (profile) {
|
||||||
|
set({
|
||||||
|
user: { id: profile.id, email: profile.email },
|
||||||
|
profile,
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
set({ user: null, profile: null, isLoading: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize session:', error);
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
set({ user: null, profile: null, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSession: (token, profile) => {
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
set({ user: { id: profile.id, email: profile.email }, profile });
|
||||||
|
},
|
||||||
|
|
||||||
|
signOut: () => {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
set({ user: null, profile: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
setProfile: (profile) => set({ profile }),
|
||||||
|
}));
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"DOM",
|
"DOM",
|
||||||
"DOM.Iterable"
|
"DOM.Iterable"
|
||||||
],
|
],
|
||||||
|
"types": ["vite/client"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export default defineConfig(({mode}) => {
|
|||||||
include: ['react', 'react-dom', 'three', '@react-three/fiber', '@react-three/drei'],
|
include: ['react', 'react-dom', 'three', '@react-three/fiber', '@react-three/drei'],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 3000,
|
||||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
// Do not modify—file watching is disabled to prevent flickering during agent edits.
|
// Do not modify—file watching is disabled to prevent flickering during agent edits.
|
||||||
hmr: process.env.DISABLE_HMR !== 'true',
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
|||||||
Reference in New Issue
Block a user