Deploy to production
This commit is contained in:
@@ -6,3 +6,6 @@ coverage/
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
backend/node_modules/
|
||||
backend/data/
|
||||
backend/uploads/
|
||||
|
||||
@@ -24,6 +24,8 @@ RUN npm run build
|
||||
FROM nginx:alpine AS production
|
||||
# Copy built assets from the build stage to Nginx
|
||||
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 80
|
||||
# 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
|
||||
build:
|
||||
context: .
|
||||
target: development
|
||||
target: production
|
||||
ports:
|
||||
- "3000:3000"
|
||||
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
|
||||
- "3001:80"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
# Optional: ensure container runs smoothly on Windows host
|
||||
stdin_open: true
|
||||
tty: true
|
||||
- NODE_ENV=production
|
||||
labels:
|
||||
- "coolify.endpoint=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",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.183.2",
|
||||
"three-stdlib": "^2.36.1",
|
||||
@@ -3827,6 +3828,57 @@
|
||||
"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": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||
@@ -4015,6 +4067,12 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"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,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -28,6 +28,7 @@
|
||||
"postprocessing": "^6.39.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.183.2",
|
||||
"three-stdlib": "^2.36.1",
|
||||
|
||||
+39
-4870
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) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<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>
|
||||
|
||||
<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 { fal } from '@fal-ai/client';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { api } from '../lib/api';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Stage } from '@react-three/drei';
|
||||
import { GLTFLoader } from 'three-stdlib';
|
||||
@@ -44,6 +46,7 @@ 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('');
|
||||
@@ -146,6 +149,10 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
setError("Please upload an image first.");
|
||||
return;
|
||||
}
|
||||
if (!profile || profile.api_credits <= 0) {
|
||||
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;
|
||||
@@ -159,6 +166,17 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
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 {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
@@ -202,6 +220,7 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
if (modelUrl) {
|
||||
setGeneratedModelUrl(modelUrl);
|
||||
setProgressMessage("Done!");
|
||||
deductCredit();
|
||||
} else {
|
||||
setError("Failed to extract 3D model from response.");
|
||||
}
|
||||
@@ -268,6 +287,7 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
setGeneratedModelUrl(modelUrl);
|
||||
setProgressMessage("Done!");
|
||||
isDone = true;
|
||||
deductCredit();
|
||||
} else if (status === 'failed' || status === 'cancelled') {
|
||||
throw new Error(`Tripo API Task ${status}`);
|
||||
}
|
||||
@@ -339,6 +359,7 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
|
||||
setGeneratedModelUrl(modelUrl);
|
||||
setProgressMessage("Done!");
|
||||
isDone = true;
|
||||
deductCredit();
|
||||
} else if (status === 'FAILED' || status === 'EXPIRED') {
|
||||
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">
|
||||
{/* Mode Tabs */}
|
||||
<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
|
||||
key={mode}
|
||||
className={cn(
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Sparkles, Image as ImageIcon, Loader2, Download, Settings, Wand2, Lightbulb } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
interface VizAiModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -22,6 +24,8 @@ const dataURLtoBlob = (dataurl: string) => {
|
||||
};
|
||||
|
||||
export const VizAiModal: React.FC<VizAiModalProps> = ({ isOpen, onClose, baseImage }) => {
|
||||
const { profile, setProfile } = useAuthStore();
|
||||
|
||||
// API Keys
|
||||
const [stabilityApiKey, setStabilityApiKey] = useState('');
|
||||
const [openAiKey, setOpenAiKey] = useState('');
|
||||
@@ -108,6 +112,11 @@ export const VizAiModal: React.FC<VizAiModalProps> = ({ isOpen, onClose, baseIma
|
||||
return;
|
||||
}
|
||||
|
||||
if (!profile || profile.api_credits <= 0) {
|
||||
setError("Not enough AI Credits. Please contact an administrator.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingImage(true);
|
||||
setError(null);
|
||||
|
||||
@@ -137,6 +146,13 @@ export const VizAiModal: React.FC<VizAiModalProps> = ({ isOpen, onClose, baseIma
|
||||
const blob = await response.blob();
|
||||
const imageUrl = URL.createObjectURL(blob);
|
||||
setGeneratedImage(imageUrl);
|
||||
|
||||
// Deduct credit
|
||||
if (profile) {
|
||||
const { api_credits } = await api.deductCredit();
|
||||
setProfile({ ...profile, api_credits });
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
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" />
|
||||
</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>
|
||||
</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.Iterable"
|
||||
],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
|
||||
@@ -22,6 +22,8 @@ export default defineConfig(({mode}) => {
|
||||
include: ['react', 'react-dom', 'three', '@react-three/fiber', '@react-three/drei'],
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 3000,
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modify—file watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
|
||||
Reference in New Issue
Block a user