Deploy to production

This commit is contained in:
AI Bot
2026-07-30 12:49:29 +05:30
parent 0d9c9eaadd
commit 07a1e8f37f
30 changed files with 6363 additions and 4887 deletions
+3
View File
@@ -6,3 +6,6 @@ coverage/
*.log *.log
.env* .env*
!.env.example !.env.example
backend/node_modules/
backend/data/
backend/uploads/
+2
View File
@@ -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
+12
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3005
CMD ["npm", "start"]
+70
View File
@@ -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 };
+18
View File
@@ -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"
}
}
+204
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+58
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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">
+21
View File
@@ -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}`);
} }
+23
View File
@@ -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 />;
};
+1 -1
View File
@@ -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(
+17 -1
View File
@@ -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
View File
@@ -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;
}
};
+81
View File
@@ -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.");
}
+56
View File
@@ -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
});
}
+83
View File
@@ -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
});
}
+19
View File
@@ -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);
});
};
+174
View 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>
);
}
+124
View File
@@ -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>
);
}
+118
View File
@@ -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>
);
}
+4951
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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>
);
}
+64
View File
@@ -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 }),
}));
+1
View File
@@ -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,
+2
View File
@@ -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',