Compare commits
10 Commits
main
...
feature/la
| Author | SHA1 | Date |
|---|---|---|
|
|
5d28f6e6ee | |
|
|
56c6ba536e | |
|
|
3015af5886 | |
|
|
98b0c1d4f7 | |
|
|
7c49f6e643 | |
|
|
fe301756d7 | |
|
|
6b1a8ff96f | |
|
|
5590787f7f | |
|
|
3579c8e4cf | |
|
|
f247191ead |
|
|
@ -81,8 +81,4 @@ uploads/
|
|||
|
||||
# Temporary files
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# Local Claude config (VPS-specific)
|
||||
CLAUDE.local.md
|
||||
.env.prod
|
||||
tmp/
|
||||
28
.mcp.json
|
|
@ -13,7 +13,10 @@
|
|||
},
|
||||
"brave-search": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-brave-search"
|
||||
],
|
||||
"env": {
|
||||
"BRAVE_API_KEY": "BSAcRGGikEzY4B2j3NZ8Qy5NYh9l4HZ"
|
||||
}
|
||||
|
|
@ -36,7 +39,10 @@
|
|||
"perplexity": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "perplexity-mcp"],
|
||||
"args": [
|
||||
"-y",
|
||||
"perplexity-mcp"
|
||||
],
|
||||
"env": {
|
||||
"PERPLEXITY_API_KEY": "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",
|
||||
"PERPLEXITY_TIMEOUT_MS": "600000"
|
||||
|
|
@ -44,7 +50,23 @@
|
|||
},
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"]
|
||||
"args": [
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest"
|
||||
]
|
||||
},
|
||||
"browsermcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@browsermcp/mcp@latest"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["shadcn@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
CLAUDE.local.md
|
|
@ -1,109 +0,0 @@
|
|||
# Banatie Service - VPS Environment
|
||||
|
||||
## Environment Context
|
||||
This Claude Code instance runs on the **VPS server** with direct access to production services.
|
||||
|
||||
Your main folder is `/home/usul/workspace/projects/banatie-service`. Use it for git operations, code review, and documentation.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/home/usul/workspace/projects/banatie-service` | Git repository (source code, docs) |
|
||||
| `/opt/banatie/` | Production deployment (docker-compose, configs) |
|
||||
| `/opt/banatie/data/` | Persistent data (minio, postgres) |
|
||||
|
||||
## Deployment Workflow
|
||||
|
||||
### Update from Git
|
||||
```bash
|
||||
cd /home/usul/workspace/projects/banatie-service
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
### Deploy API
|
||||
```bash
|
||||
./scripts/deploy-api.sh
|
||||
# or with rebuild:
|
||||
./scripts/deploy-api.sh --no-cache
|
||||
```
|
||||
|
||||
### Deploy Landing
|
||||
```bash
|
||||
./scripts/deploy-landing.sh
|
||||
```
|
||||
|
||||
### Manual Docker Operations
|
||||
```bash
|
||||
cd /opt/banatie
|
||||
docker compose ps
|
||||
docker compose logs -f api
|
||||
docker compose logs -f landing
|
||||
docker compose restart api
|
||||
```
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Check Service Status
|
||||
```bash
|
||||
docker compose -f /opt/banatie/docker-compose.yml ps
|
||||
curl -s http://localhost:3000/health | jq
|
||||
```
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
docker compose -f /opt/banatie/docker-compose.yml logs -f api --tail=100
|
||||
docker compose -f /opt/banatie/docker-compose.yml logs -f landing --tail=100
|
||||
```
|
||||
|
||||
### Database Access
|
||||
```bash
|
||||
docker exec -it banatie-postgres psql -U banatie_user -d banatie_db
|
||||
```
|
||||
|
||||
### MinIO Access
|
||||
```bash
|
||||
docker exec -it banatie-minio mc ls storage/banatie
|
||||
```
|
||||
|
||||
### Reset Database (DESTRUCTIVE)
|
||||
```bash
|
||||
cd /opt/banatie
|
||||
docker compose down
|
||||
sudo rm -rf data/postgres/*
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Production URLs
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Landing | https://banatie.app |
|
||||
| API | https://api.banatie.app |
|
||||
| CDN | https://cdn.banatie.app |
|
||||
| MinIO Console | https://storage.banatie.app |
|
||||
|
||||
## Key Files
|
||||
|
||||
| Location | Purpose |
|
||||
|----------|---------|
|
||||
| `/opt/banatie/docker-compose.yml` | Production compose (copy from `infrastructure/docker-compose.vps.yml`) |
|
||||
| `/opt/banatie/.env` | Environment variables |
|
||||
| `/opt/banatie/secrets.env` | Secrets (GEMINI_API_KEY, etc.) |
|
||||
| `infrastructure/docker-compose.vps.yml` | Source template for production compose |
|
||||
| `docs/url-fix-vps-site.md` | CDN deployment instructions |
|
||||
|
||||
## Operational Responsibilities
|
||||
|
||||
- Execute deployment procedures using scripts in `scripts/`
|
||||
- Monitor service health via Docker logs
|
||||
- Update configurations and restart services as needed
|
||||
- Document encountered issues and their resolutions
|
||||
- Commit operational changes and lessons learned to the repository
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always `git pull` before deploying
|
||||
- Check logs after deployment for errors
|
||||
- Secrets are in `/opt/banatie/secrets.env` (not in git)
|
||||
- Database and MinIO data persist in `/opt/banatie/data/`
|
||||
46
CLAUDE.md
|
|
@ -300,52 +300,6 @@ curl -X POST http://localhost:3000/api/upload \
|
|||
- **Rate Limits**: 100 requests per hour per key
|
||||
- **Revocation**: Soft delete via `is_active` flag
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### VPS Infrastructure
|
||||
|
||||
Banatie is deployed as an isolated ecosystem on VPS at `/opt/banatie/`:
|
||||
|
||||
| Service | URL | Container |
|
||||
|---------|-----|-----------|
|
||||
| Landing | https://banatie.app | banatie-landing |
|
||||
| API | https://api.banatie.app | banatie-api |
|
||||
| MinIO Console | https://storage.banatie.app | banatie-minio |
|
||||
| MinIO CDN | https://cdn.banatie.app | banatie-minio |
|
||||
|
||||
### Deploy Scripts
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
./scripts/deploy-landing.sh # Deploy landing
|
||||
./scripts/deploy-landing.sh --no-cache # Force rebuild (when deps change)
|
||||
./scripts/deploy-api.sh # Deploy API
|
||||
./scripts/deploy-api.sh --no-cache # Force rebuild
|
||||
```
|
||||
|
||||
### Production Configuration Files
|
||||
|
||||
```
|
||||
infrastructure/
|
||||
├── docker-compose.production.yml # VPS docker-compose
|
||||
├── .env.example # Environment variables template
|
||||
└── secrets.env.example # Secrets template
|
||||
```
|
||||
|
||||
### Key Production Learnings
|
||||
|
||||
1. **NEXT_PUBLIC_* variables** - Must be set at build time AND runtime for Next.js client-side code
|
||||
2. **pnpm workspaces in Docker** - Symlinks break between stages; use single-stage install with `pnpm --filter`
|
||||
3. **Docker User NS Remapping** - VPS uses UID offset 165536; container UID 1001 → host UID 166537
|
||||
4. **DATABASE_URL encoding** - Special characters like `=` must be URL-encoded (`%3D`)
|
||||
|
||||
### Known Production Issues (Non-Critical)
|
||||
|
||||
1. **Healthcheck showing "unhealthy"** - Alpine images lack curl; services work correctly
|
||||
2. **Next.js cache permission** - `.next/cache` may show EACCES; non-critical for functionality
|
||||
|
||||
See [docs/deployment.md](docs/deployment.md) for full deployment guide.
|
||||
|
||||
## Development Notes
|
||||
|
||||
- Uses pnpm workspaces for monorepo management (required >= 8.0.0)
|
||||
|
|
|
|||
|
|
@ -1,42 +1,90 @@
|
|||
# Simplified Dockerfile for API Service
|
||||
# Multi-stage Dockerfile for API Service
|
||||
|
||||
# Stage 1: Dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
|
||||
# Copy workspace configuration
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
|
||||
# Copy package.json files
|
||||
COPY apps/api-service/package.json ./apps/api-service/
|
||||
COPY packages/database/package.json ./packages/database/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
|
||||
# Copy everything needed
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/api-service/node_modules ./apps/api-service/node_modules
|
||||
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||
|
||||
# Copy workspace files
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY apps/api-service ./apps/api-service
|
||||
|
||||
# Copy database package
|
||||
COPY packages/database ./packages/database
|
||||
|
||||
# Install and build
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @banatie/database build
|
||||
RUN pnpm --filter @banatie/api-service build
|
||||
# Copy API service source (exclude .env - it's for local dev only)
|
||||
COPY apps/api-service/package.json ./apps/api-service/
|
||||
COPY apps/api-service/tsconfig.json ./apps/api-service/
|
||||
COPY apps/api-service/src ./apps/api-service/src
|
||||
|
||||
# Production runner
|
||||
# Set working directory to API service
|
||||
WORKDIR /app/apps/api-service
|
||||
|
||||
# Build TypeScript
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 3: Production Runner
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 apiuser
|
||||
|
||||
# Copy built app
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages/database ./packages/database
|
||||
COPY --from=builder /app/apps/api-service/dist ./apps/api-service/dist
|
||||
COPY --from=builder /app/apps/api-service/package.json ./apps/api-service/
|
||||
COPY --from=builder /app/apps/api-service/node_modules ./apps/api-service/node_modules
|
||||
# Copy workspace configuration
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
|
||||
# Create directories
|
||||
# Copy database package
|
||||
COPY --from=builder /app/packages/database ./packages/database
|
||||
|
||||
# Copy built API service
|
||||
COPY --from=builder --chown=apiuser:nodejs /app/apps/api-service/dist ./apps/api-service/dist
|
||||
COPY --from=builder /app/apps/api-service/package.json ./apps/api-service/
|
||||
|
||||
# Copy node_modules for runtime
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps/api-service/node_modules ./apps/api-service/node_modules
|
||||
COPY --from=builder /app/packages/database/node_modules ./packages/database/node_modules
|
||||
|
||||
# Create directories for logs and data
|
||||
RUN mkdir -p /app/apps/api-service/logs /app/results /app/uploads/temp
|
||||
RUN chown -R apiuser:nodejs /app/apps/api-service/logs /app/results /app/uploads
|
||||
|
||||
USER apiuser
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
WORKDIR /app/apps/api-service
|
||||
|
||||
# Run production build
|
||||
CMD ["node", "dist/server.js"]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"infra:logs": "docker compose logs -f",
|
||||
"dev": "npm run infra:up && echo 'Logs will be saved to api-dev.log' && tsx --watch src/server.ts 2>&1 | tee api-dev.log",
|
||||
"start": "node dist/server.js",
|
||||
"build": "tsc && tsc-alias",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"lint:fix": "eslint src/**/*.ts --fix",
|
||||
|
|
@ -72,7 +72,6 @@
|
|||
"prettier": "^3.4.2",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createDbClient, type DbClient } from '@banatie/database';
|
||||
import { createDbClient } from '@banatie/database';
|
||||
import { config } from 'dotenv';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
|
@ -20,7 +20,7 @@ const DATABASE_URL =
|
|||
process.env['DATABASE_URL'] ||
|
||||
'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db';
|
||||
|
||||
export const db: DbClient = createDbClient(DATABASE_URL);
|
||||
export const db = createDbClient(DATABASE_URL);
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ import { Request, Response, NextFunction } from 'express';
|
|||
*/
|
||||
export function requireMasterKey(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.apiKey) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'This endpoint requires authentication',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.apiKey.keyType !== 'master') {
|
||||
|
|
@ -18,11 +17,10 @@ export function requireMasterKey(req: Request, res: Response, next: NextFunction
|
|||
`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`,
|
||||
);
|
||||
|
||||
res.status(403).json({
|
||||
return res.status(403).json({
|
||||
error: 'Master key required',
|
||||
message: 'This endpoint requires a master API key',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -7,30 +7,27 @@ import { Request, Response, NextFunction } from 'express';
|
|||
export function requireProjectKey(req: Request, res: Response, next: NextFunction): void {
|
||||
// This middleware assumes validateApiKey has already run and attached req.apiKey
|
||||
if (!req.apiKey) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'API key validation must be performed first',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Block master keys from generation endpoints
|
||||
if (req.apiKey.keyType === 'master') {
|
||||
res.status(403).json({
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'Master keys cannot be used for image generation. Please use a project-specific API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure project key has required IDs
|
||||
if (!req.apiKey.projectId) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key',
|
||||
message: 'Project key must be associated with a project',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
|
|
|
|||
|
|
@ -23,22 +23,20 @@ export async function validateApiKey(
|
|||
const providedKey = req.headers['x-api-key'] as string;
|
||||
|
||||
if (!providedKey) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
error: 'Missing API key',
|
||||
message: 'Provide your API key via X-API-Key header',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await apiKeyService.validateKey(providedKey);
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(401).json({
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: 'The provided API key is invalid, expired, or revoked',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach to request for use in routes
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import express, { Router } from 'express';
|
||||
import express from 'express';
|
||||
import { ApiKeyService } from '../../services/ApiKeyService';
|
||||
import { validateApiKey } from '../../middleware/auth/validateApiKey';
|
||||
import { requireMasterKey } from '../../middleware/auth/requireMasterKey';
|
||||
|
||||
const router: Router = express.Router();
|
||||
const router = express.Router();
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
||||
// All admin routes require master key
|
||||
|
|
@ -14,12 +14,12 @@ router.use(requireMasterKey);
|
|||
* Create a new API key
|
||||
* POST /api/admin/keys
|
||||
*/
|
||||
router.post('/', async (req, res): Promise<void> => {
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
type,
|
||||
projectId: _projectId,
|
||||
organizationId: _organizationId,
|
||||
projectId,
|
||||
organizationId,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
organizationName,
|
||||
|
|
@ -30,27 +30,24 @@ router.post('/', async (req, res): Promise<void> => {
|
|||
|
||||
// Validation
|
||||
if (!type || !['master', 'project'].includes(type)) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
error: 'Invalid type',
|
||||
message: 'Type must be either "master" or "project"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'project' && !projectSlug) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
error: 'Missing projectSlug',
|
||||
message: 'Project keys require a projectSlug',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'project' && !organizationSlug) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
error: 'Missing organizationSlug',
|
||||
message: 'Project keys require an organizationSlug',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create key
|
||||
|
|
@ -151,18 +148,17 @@ router.get('/', async (req, res) => {
|
|||
* Revoke an API key
|
||||
* DELETE /api/admin/keys/:keyId
|
||||
*/
|
||||
router.delete('/:keyId', async (req, res): Promise<void> => {
|
||||
router.delete('/:keyId', async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
|
||||
const success = await apiKeyService.revokeKey(keyId);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
return res.status(404).json({
|
||||
error: 'Key not found',
|
||||
message: 'The specified API key does not exist',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${new Date().toISOString()}] API key revoked: ${keyId} - by: ${req.apiKey!.id}`);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import express, { Router } from 'express';
|
||||
import express from 'express';
|
||||
import { ApiKeyService } from '../services/ApiKeyService';
|
||||
|
||||
const router: Router = express.Router();
|
||||
const router = express.Router();
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
||||
/**
|
||||
|
|
@ -10,18 +10,17 @@ const apiKeyService = new ApiKeyService();
|
|||
*
|
||||
* POST /api/bootstrap/initial-key
|
||||
*/
|
||||
router.post('/initial-key', async (_req, res): Promise<void> => {
|
||||
router.post('/initial-key', async (req, res) => {
|
||||
try {
|
||||
// Check if any keys already exist
|
||||
const hasKeys = await apiKeyService.hasAnyKeys();
|
||||
|
||||
if (hasKeys) {
|
||||
console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`);
|
||||
res.status(403).json({
|
||||
return res.status(403).json({
|
||||
error: 'Bootstrap not allowed',
|
||||
message: 'API keys already exist. Use /api/admin/keys to create new keys.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create first master key
|
||||
|
|
|
|||
|
|
@ -144,19 +144,19 @@ cdnRouter.get(
|
|||
}
|
||||
|
||||
// Download image from storage
|
||||
// Storage key format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
const keyParts = image.storageKey.split('/');
|
||||
|
||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
const storedOrgSlug = keyParts[0]!;
|
||||
const storedProjectSlug = keyParts[1]!;
|
||||
const imageId = keyParts[3]!;
|
||||
const orgId = keyParts[0]!;
|
||||
const projectId = keyParts[1]!;
|
||||
const category = keyParts[2]! as 'uploads' | 'generated' | 'references';
|
||||
const filename = keyParts.slice(3).join('/');
|
||||
|
||||
const buffer = await storageService.downloadFile(storedOrgSlug, storedProjectSlug, imageId);
|
||||
const buffer = await storageService.downloadFile(orgId, projectId, category, filename);
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', image.mimeType);
|
||||
|
|
@ -345,19 +345,19 @@ cdnRouter.get(
|
|||
|
||||
if (cachedImage) {
|
||||
// Cache HIT - serve existing image
|
||||
// Storage key format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
const keyParts = cachedImage.storageKey.split('/');
|
||||
|
||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
const storedOrgSlug = keyParts[0]!;
|
||||
const storedProjectSlug = keyParts[1]!;
|
||||
const imageId = keyParts[3]!;
|
||||
const orgId = keyParts[0]!;
|
||||
const projectId = keyParts[1]!;
|
||||
const category = keyParts[2]! as 'uploads' | 'generated' | 'references';
|
||||
const filename = keyParts.slice(3).join('/');
|
||||
|
||||
const buffer = await storageService.downloadFile(storedOrgSlug, storedProjectSlug, imageId);
|
||||
const buffer = await storageService.downloadFile(orgId, projectId, category, filename);
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', cachedImage.mimeType);
|
||||
|
|
@ -422,12 +422,10 @@ cdnRouter.get(
|
|||
const generation = await genService.create({
|
||||
projectId: project.id,
|
||||
apiKeyId: null as unknown as string, // System generation for live URLs
|
||||
organizationSlug: orgSlug,
|
||||
projectSlug: projectSlug,
|
||||
prompt,
|
||||
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
autoEnhance: normalizedAutoEnhance,
|
||||
requestId: req.requestId,
|
||||
requestId: `live-${scope}-${Date.now()}`,
|
||||
});
|
||||
|
||||
if (!generation.outputImage) {
|
||||
|
|
@ -445,19 +443,19 @@ cdnRouter.get(
|
|||
});
|
||||
|
||||
// Download newly generated image
|
||||
// Storage key format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
const keyParts = generation.outputImage.storageKey.split('/');
|
||||
|
||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
const storedOrgSlug = keyParts[0]!;
|
||||
const storedProjectSlug = keyParts[1]!;
|
||||
const imageId = keyParts[3]!;
|
||||
const orgId = keyParts[0]!;
|
||||
const projectId = keyParts[1]!;
|
||||
const category = keyParts[2]! as 'uploads' | 'generated' | 'references';
|
||||
const filename = keyParts.slice(3).join('/');
|
||||
|
||||
const buffer = await storageService.downloadFile(storedOrgSlug, storedProjectSlug, imageId);
|
||||
const buffer = await storageService.downloadFile(orgId, projectId, category, filename);
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', generation.outputImage.mimeType);
|
||||
|
|
|
|||
|
|
@ -1,60 +1,77 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { StorageFactory } from '../services/StorageFactory';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
||||
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
|
||||
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
|
||||
|
||||
export const imagesRouter: RouterType = Router();
|
||||
export const imagesRouter = Router();
|
||||
|
||||
/**
|
||||
* GET /api/images/:orgSlug/:projectSlug/img/:imageId
|
||||
* Serves images directly (streaming approach)
|
||||
* New format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
* GET /api/images/:orgId/:projectId/:category/:filename
|
||||
* Serves images via presigned URLs (redirect approach)
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/images/:orgSlug/:projectSlug/img/:imageId',
|
||||
asyncHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { orgSlug, projectSlug, imageId } = req.params;
|
||||
'/images/:orgId/:projectId/:category/:filename',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { orgId, projectId, category, filename } = req.params;
|
||||
|
||||
// Validate required params (these are guaranteed by route pattern)
|
||||
if (!orgSlug || !projectSlug || !imageId) {
|
||||
res.status(400).json({
|
||||
// Validate category
|
||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required parameters',
|
||||
message: 'Invalid category',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
try {
|
||||
// Check if file exists first (fast check)
|
||||
const exists = await storageService.fileExists(orgSlug, projectSlug, imageId);
|
||||
const exists = await storageService.fileExists(
|
||||
orgId,
|
||||
projectId,
|
||||
category as 'uploads' | 'generated' | 'references',
|
||||
filename,
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
res.status(404).json({
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'File not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine content type from filename
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
const contentType =
|
||||
{
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
}[ext || ''] || 'application/octet-stream';
|
||||
|
||||
// Set headers for optimal caching and performance
|
||||
// Note: Content-Type will be set from MinIO metadata
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1 year + immutable
|
||||
res.setHeader('ETag', `"${imageId}"`); // UUID as ETag
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable
|
||||
res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag
|
||||
|
||||
// Handle conditional requests (304 Not Modified)
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
if (ifNoneMatch === `"${imageId}"`) {
|
||||
res.status(304).end(); // Not Modified
|
||||
return;
|
||||
if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
|
||||
return res.status(304).end(); // Not Modified
|
||||
}
|
||||
|
||||
// Stream the file directly through our API (memory efficient)
|
||||
const fileStream = await storageService.streamFile(orgSlug, projectSlug, imageId);
|
||||
const fileStream = await storageService.streamFile(
|
||||
orgId,
|
||||
projectId,
|
||||
category as 'uploads' | 'generated' | 'references',
|
||||
filename,
|
||||
);
|
||||
|
||||
// Handle stream errors
|
||||
fileStream.on('error', (streamError) => {
|
||||
|
|
@ -71,7 +88,7 @@ imagesRouter.get(
|
|||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Failed to stream file:', error);
|
||||
res.status(404).json({
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'File not found',
|
||||
});
|
||||
|
|
@ -80,42 +97,41 @@ imagesRouter.get(
|
|||
);
|
||||
|
||||
/**
|
||||
* GET /api/images/url/:orgSlug/:projectSlug/img/:imageId
|
||||
* GET /api/images/url/:orgId/:projectId/:category/:filename
|
||||
* Returns a presigned URL instead of redirecting
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/images/url/:orgSlug/:projectSlug/img/:imageId',
|
||||
asyncHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { orgSlug, projectSlug, imageId } = req.params;
|
||||
'/images/url/:orgId/:projectId/:category/:filename',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { orgId, projectId, category, filename } = req.params;
|
||||
const { expiry = '3600' } = req.query; // Default 1 hour
|
||||
|
||||
// Validate required params (these are guaranteed by route pattern)
|
||||
if (!orgSlug || !projectSlug || !imageId) {
|
||||
res.status(400).json({
|
||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required parameters',
|
||||
message: 'Invalid category',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
try {
|
||||
const presignedUrl = await storageService.getPresignedDownloadUrl(
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
imageId,
|
||||
orgId,
|
||||
projectId,
|
||||
category as 'uploads' | 'generated' | 'references',
|
||||
filename,
|
||||
parseInt(expiry as string, 10),
|
||||
);
|
||||
|
||||
res.json({
|
||||
return res.json({
|
||||
success: true,
|
||||
url: presignedUrl,
|
||||
expiresIn: parseInt(expiry as string, 10),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate presigned URL:', error);
|
||||
res.status(404).json({
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'File not found or access denied',
|
||||
});
|
||||
|
|
@ -143,28 +159,27 @@ imagesRouter.get(
|
|||
|
||||
// Validate query parameters
|
||||
if (isNaN(limit) || isNaN(offset)) {
|
||||
res.status(400).json({
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid query parameters',
|
||||
error: 'limit and offset must be valid numbers',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract org/project from validated API key
|
||||
const orgSlug = req.apiKey?.organizationSlug || 'default';
|
||||
const projectSlug = req.apiKey?.projectSlug!;
|
||||
const orgId = req.apiKey?.organizationSlug || 'default';
|
||||
const projectId = req.apiKey?.projectSlug!;
|
||||
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Listing images for org:${orgSlug}, project:${projectSlug}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`,
|
||||
`[${timestamp}] [${requestId}] Listing generated images for org:${orgId}, project:${projectId}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Get storage service instance
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
// List files in img folder
|
||||
const allFiles = await storageService.listFiles(orgSlug, projectSlug, prefix);
|
||||
// List files in generated category
|
||||
const allFiles = await storageService.listFiles(orgId, projectId, 'generated', prefix);
|
||||
|
||||
// Sort by lastModified descending (newest first)
|
||||
allFiles.sort((a, b) => {
|
||||
|
|
@ -179,8 +194,8 @@ imagesRouter.get(
|
|||
|
||||
// Map to response format with public URLs
|
||||
const images = paginatedFiles.map((file) => ({
|
||||
imageId: file.filename,
|
||||
url: storageService.getPublicUrl(orgSlug, projectSlug, file.filename),
|
||||
filename: file.filename,
|
||||
url: storageService.getPublicUrl(orgId, projectId, 'generated', file.filename),
|
||||
size: file.size,
|
||||
contentType: file.contentType,
|
||||
lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(),
|
||||
|
|
@ -189,7 +204,7 @@ imagesRouter.get(
|
|||
const hasMore = offset + limit < total;
|
||||
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} images`,
|
||||
`[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} generated images`,
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
|
|
@ -203,11 +218,11 @@ imagesRouter.get(
|
|||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[${timestamp}] [${requestId}] Failed to list images:`, error);
|
||||
console.error(`[${timestamp}] [${requestId}] Failed to list generated images:`, error);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to list images',
|
||||
message: 'Failed to list generated images',
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ImageGenService } from '../services/ImageGenService';
|
||||
import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation';
|
||||
import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement';
|
||||
|
|
@ -49,17 +48,14 @@ textToImageRouter.post(
|
|||
|
||||
const timestamp = new Date().toISOString();
|
||||
const requestId = req.requestId;
|
||||
const { prompt, aspectRatio, meta } = req.body;
|
||||
const { prompt, filename, aspectRatio, meta } = req.body;
|
||||
|
||||
// Extract org/project slugs from validated API key
|
||||
const orgSlug = req.apiKey?.organizationSlug || undefined;
|
||||
const projectSlug = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||
|
||||
// Generate imageId (UUID) - this will be the filename in storage
|
||||
const imageId = randomUUID();
|
||||
const orgId = req.apiKey?.organizationSlug || undefined;
|
||||
const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgSlug}, project:${projectSlug}`,
|
||||
`[${timestamp}] [${requestId}] Starting text-to-image generation process for org:${orgId}, project:${projectId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
|
|
@ -70,10 +66,10 @@ textToImageRouter.post(
|
|||
|
||||
const result = await imageGenService.generateImage({
|
||||
prompt,
|
||||
imageId,
|
||||
filename,
|
||||
...(aspectRatio && { aspectRatio }),
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
orgId,
|
||||
projectId,
|
||||
...(meta && { meta }),
|
||||
});
|
||||
|
||||
|
|
@ -81,7 +77,7 @@ textToImageRouter.post(
|
|||
console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, {
|
||||
success: result.success,
|
||||
model: result.model,
|
||||
imageId: result.imageId,
|
||||
filename: result.filename,
|
||||
hasError: !!result.error,
|
||||
});
|
||||
|
||||
|
|
@ -91,7 +87,7 @@ textToImageRouter.post(
|
|||
success: true,
|
||||
message: 'Image generated successfully',
|
||||
data: {
|
||||
filename: result.imageId!,
|
||||
filename: result.filename!,
|
||||
filepath: result.filepath!,
|
||||
...(result.url && { url: result.url }),
|
||||
...(result.description && { description: result.description }),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { StorageFactory } from '../services/StorageFactory';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
||||
|
|
@ -41,11 +40,11 @@ uploadRouter.post(
|
|||
}
|
||||
|
||||
// Extract org/project slugs from validated API key
|
||||
const orgSlug = req.apiKey?.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
||||
const projectSlug = req.apiKey?.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main'; // Guaranteed by requireProjectKey middleware
|
||||
const orgId = req.apiKey?.organizationSlug || 'default';
|
||||
const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Starting file upload for org:${orgSlug}, project:${projectSlug}`,
|
||||
`[${timestamp}] [${requestId}] Starting file upload for org:${orgId}, project:${projectId}`,
|
||||
);
|
||||
|
||||
const file = req.file;
|
||||
|
|
@ -54,22 +53,18 @@ uploadRouter.post(
|
|||
// Initialize storage service
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
// Generate imageId (UUID) - this will be the filename in storage
|
||||
const imageId = randomUUID();
|
||||
|
||||
// Upload file to MinIO
|
||||
// Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
// Upload file to MinIO in 'uploads' category
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Uploading file: ${file.originalname} as ${imageId} (${file.size} bytes)`,
|
||||
`[${timestamp}] [${requestId}] Uploading file: ${file.originalname} (${file.size} bytes)`,
|
||||
);
|
||||
|
||||
const uploadResult = await storageService.uploadFile(
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
imageId,
|
||||
orgId,
|
||||
projectId,
|
||||
'uploads',
|
||||
file.originalname,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
file.originalname,
|
||||
);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { validateAndNormalizePagination } from '@/utils/validators';
|
|||
import { buildPaginatedResponse } from '@/utils/helpers';
|
||||
import { toFlowResponse, toGenerationResponse, toImageResponse } from '@/types/responses';
|
||||
import type {
|
||||
CreateFlowResponse,
|
||||
ListFlowsResponse,
|
||||
GetFlowResponse,
|
||||
UpdateFlowAliasesResponse,
|
||||
|
|
|
|||
|
|
@ -114,14 +114,10 @@ generationsRouter.post(
|
|||
|
||||
const projectId = req.apiKey.projectId;
|
||||
const apiKeyId = req.apiKey.id;
|
||||
const organizationSlug = req.apiKey.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
||||
const projectSlug = req.apiKey.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main';
|
||||
|
||||
const generation = await service.create({
|
||||
projectId,
|
||||
apiKeyId,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
prompt,
|
||||
referenceImages,
|
||||
aspectRatio,
|
||||
|
|
|
|||
|
|
@ -172,26 +172,24 @@ imagesRouter.post(
|
|||
pendingFlowId = null;
|
||||
} else {
|
||||
// Specific flowId provided - ensure flow exists (eager creation)
|
||||
// Use flowId directly since TypeScript has narrowed it to string in this branch
|
||||
const providedFlowId = flowId;
|
||||
finalFlowId = providedFlowId;
|
||||
finalFlowId = flowId;
|
||||
pendingFlowId = null;
|
||||
|
||||
// Check if flow exists, create if not
|
||||
const existingFlow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, providedFlowId),
|
||||
where: eq(flows.id, finalFlowId),
|
||||
});
|
||||
|
||||
if (!existingFlow) {
|
||||
await db.insert(flows).values({
|
||||
id: providedFlowId,
|
||||
id: finalFlowId,
|
||||
projectId,
|
||||
aliases: {},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// Link any pending images to this new flow
|
||||
await service.linkPendingImagesToFlow(providedFlowId, projectId);
|
||||
await service.linkPendingImagesToFlow(finalFlowId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,6 @@ liveRouter.get(
|
|||
|
||||
const projectId = req.apiKey.projectId;
|
||||
const apiKeyId = req.apiKey.id;
|
||||
const organizationSlug = req.apiKey.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
||||
const projectSlug = req.apiKey.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main';
|
||||
|
||||
try {
|
||||
// Compute prompt hash for cache lookup
|
||||
|
|
@ -88,21 +86,23 @@ liveRouter.get(
|
|||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
// Parse storage key to get components
|
||||
// Format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
// Format: orgId/projectId/category/filename.ext
|
||||
const keyParts = image.storageKey.split('/');
|
||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
const storedOrgSlug = keyParts[0]!;
|
||||
const storedProjectSlug = keyParts[1]!;
|
||||
const imageId = keyParts[3]!;
|
||||
const orgId = keyParts[0];
|
||||
const projectIdSlug = keyParts[1];
|
||||
const category = keyParts[2] as 'uploads' | 'generated' | 'references';
|
||||
const filename = keyParts.slice(3).join('/');
|
||||
|
||||
// Download image from storage
|
||||
const buffer = await storageService.downloadFile(
|
||||
storedOrgSlug,
|
||||
storedProjectSlug,
|
||||
imageId
|
||||
orgId!,
|
||||
projectIdSlug!,
|
||||
category,
|
||||
filename!
|
||||
);
|
||||
|
||||
// Set cache headers
|
||||
|
|
@ -122,8 +122,6 @@ liveRouter.get(
|
|||
const generation = await genService.create({
|
||||
projectId,
|
||||
apiKeyId,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
prompt,
|
||||
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
requestId: req.requestId,
|
||||
|
|
@ -155,20 +153,22 @@ liveRouter.get(
|
|||
// Download newly generated image
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
// Format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
// Format: orgId/projectId/category/filename.ext
|
||||
const keyParts = generation.outputImage.storageKey.split('/');
|
||||
if (keyParts.length < 4 || keyParts[2] !== 'img') {
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
const storedOrgSlug = keyParts[0]!;
|
||||
const storedProjectSlug = keyParts[1]!;
|
||||
const imageId = keyParts[3]!;
|
||||
const orgId = keyParts[0];
|
||||
const projectIdSlug = keyParts[1];
|
||||
const category = keyParts[2] as 'uploads' | 'generated' | 'references';
|
||||
const filename = keyParts.slice(3).join('/');
|
||||
|
||||
const buffer = await storageService.downloadFile(
|
||||
storedOrgSlug,
|
||||
storedProjectSlug,
|
||||
imageId
|
||||
orgId!,
|
||||
projectIdSlug!,
|
||||
category,
|
||||
filename!
|
||||
);
|
||||
|
||||
// Set cache headers
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import crypto from 'crypto';
|
||||
import { db } from '../db';
|
||||
import { apiKeys, organizations, projects, type ApiKey } from '@banatie/database';
|
||||
import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from '@banatie/database';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
|
||||
// Extended API key type with slugs for storage paths
|
||||
|
|
|
|||
|
|
@ -12,13 +12,10 @@ import {
|
|||
import { StorageFactory } from './StorageFactory';
|
||||
import { TTILogger, TTILogEntry } from './TTILogger';
|
||||
import { NetworkErrorDetector } from '../utils/NetworkErrorDetector';
|
||||
import { GeminiErrorDetector } from '../utils/GeminiErrorDetector';
|
||||
import { ERROR_MESSAGES } from '../utils/constants/errors';
|
||||
|
||||
export class ImageGenService {
|
||||
private ai: GoogleGenAI;
|
||||
private primaryModel = 'gemini-2.5-flash-image';
|
||||
private static GEMINI_TIMEOUT_MS = 90_000; // 90 seconds
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
|
|
@ -32,12 +29,12 @@ export class ImageGenService {
|
|||
* This method separates image generation from storage for clear error handling
|
||||
*/
|
||||
async generateImage(options: ImageGenerationOptions): Promise<ImageGenerationResult> {
|
||||
const { prompt, imageId, referenceImages, aspectRatio, orgSlug, projectSlug, meta } = options;
|
||||
const { prompt, filename, referenceImages, aspectRatio, orgId, projectId, meta } = options;
|
||||
|
||||
// Use default values if not provided
|
||||
const finalOrgSlug = orgSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
||||
const finalProjectSlug = projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main';
|
||||
const finalAspectRatio = aspectRatio || '16:9'; // Default to widescreen
|
||||
const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default';
|
||||
const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main';
|
||||
const finalAspectRatio = aspectRatio || '1:1'; // Default to square
|
||||
|
||||
// Step 1: Generate image from Gemini AI
|
||||
let generatedData: GeneratedImageData;
|
||||
|
|
@ -47,8 +44,8 @@ export class ImageGenService {
|
|||
prompt,
|
||||
referenceImages,
|
||||
finalAspectRatio,
|
||||
finalOrgSlug,
|
||||
finalProjectSlug,
|
||||
finalOrgId,
|
||||
finalProjectId,
|
||||
meta,
|
||||
);
|
||||
generatedData = aiResult.generatedData;
|
||||
|
|
@ -64,25 +61,22 @@ export class ImageGenService {
|
|||
}
|
||||
|
||||
// Step 2: Save generated image to storage
|
||||
// Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
try {
|
||||
const finalFilename = `${filename}.${generatedData.fileExtension}`;
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
// Original filename for metadata (e.g., "my-image.png")
|
||||
const originalFilename = `generated-image.${generatedData.fileExtension}`;
|
||||
|
||||
const uploadResult = await storageService.uploadFile(
|
||||
finalOrgSlug,
|
||||
finalProjectSlug,
|
||||
imageId,
|
||||
finalOrgId,
|
||||
finalProjectId,
|
||||
'generated',
|
||||
finalFilename,
|
||||
generatedData.buffer,
|
||||
generatedData.mimeType,
|
||||
originalFilename,
|
||||
);
|
||||
|
||||
if (uploadResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
imageId: uploadResult.filename,
|
||||
filename: uploadResult.filename,
|
||||
filepath: uploadResult.path,
|
||||
url: uploadResult.url,
|
||||
size: uploadResult.size,
|
||||
|
|
@ -131,8 +125,8 @@ export class ImageGenService {
|
|||
prompt: string,
|
||||
referenceImages: ReferenceImage[] | undefined,
|
||||
aspectRatio: string,
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
meta?: { tags?: string[] },
|
||||
): Promise<{
|
||||
generatedData: GeneratedImageData;
|
||||
|
|
@ -188,8 +182,8 @@ export class ImageGenService {
|
|||
const ttiLogger = TTILogger.getInstance();
|
||||
const logEntry: TTILogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
orgId: orgSlug,
|
||||
projectId: projectSlug,
|
||||
orgId,
|
||||
projectId,
|
||||
prompt,
|
||||
model: this.primaryModel,
|
||||
config,
|
||||
|
|
@ -208,56 +202,18 @@ export class ImageGenService {
|
|||
|
||||
try {
|
||||
// Use the EXACT same config and contents objects calculated above
|
||||
// Wrap with timeout to prevent hanging requests
|
||||
const response = await this.withTimeout(
|
||||
this.ai.models.generateContent({
|
||||
model: this.primaryModel,
|
||||
config,
|
||||
contents,
|
||||
}),
|
||||
ImageGenService.GEMINI_TIMEOUT_MS,
|
||||
'Gemini image generation'
|
||||
);
|
||||
const response = await this.ai.models.generateContent({
|
||||
model: this.primaryModel,
|
||||
config,
|
||||
contents,
|
||||
});
|
||||
|
||||
// Log response structure for debugging
|
||||
GeminiErrorDetector.logResponseStructure(response as any);
|
||||
|
||||
// Check promptFeedback for blocked prompts FIRST
|
||||
if ((response as any).promptFeedback?.blockReason) {
|
||||
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
|
||||
console.error(
|
||||
`[ImageGenService] Prompt blocked:`,
|
||||
GeminiErrorDetector.formatForLogging(errorResult!)
|
||||
);
|
||||
throw new Error(errorResult!.message);
|
||||
// Parse response
|
||||
if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) {
|
||||
throw new Error('No response received from Gemini AI');
|
||||
}
|
||||
|
||||
// Check if we have candidates
|
||||
if (!response.candidates || !response.candidates[0]) {
|
||||
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
|
||||
console.error(`[ImageGenService] No candidates in response`);
|
||||
throw new Error(errorResult?.message || 'No response candidates from Gemini AI');
|
||||
}
|
||||
|
||||
const candidate = response.candidates[0];
|
||||
|
||||
// Check finishReason for non-STOP completions
|
||||
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
|
||||
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
|
||||
console.error(
|
||||
`[ImageGenService] Non-STOP finish reason:`,
|
||||
GeminiErrorDetector.formatForLogging(errorResult!)
|
||||
);
|
||||
throw new Error(errorResult!.message);
|
||||
}
|
||||
|
||||
// Check content exists
|
||||
if (!candidate.content) {
|
||||
console.error(`[ImageGenService] No content in candidate`);
|
||||
throw new Error('No content in Gemini AI response');
|
||||
}
|
||||
|
||||
const content = candidate.content;
|
||||
const content = response.candidates[0].content;
|
||||
let generatedDescription: string | undefined;
|
||||
let imageData: { buffer: Buffer; mimeType: string } | null = null;
|
||||
|
||||
|
|
@ -273,14 +229,7 @@ export class ImageGenService {
|
|||
}
|
||||
|
||||
if (!imageData) {
|
||||
// Log what we got instead of image
|
||||
const partTypes = (content.parts || []).map((p: any) =>
|
||||
p.inlineData ? 'image' : p.text ? 'text' : 'other'
|
||||
);
|
||||
console.error(`[ImageGenService] No image data in response. Parts: [${partTypes.join(', ')}]`);
|
||||
throw new Error(
|
||||
`${ERROR_MESSAGES.GEMINI_NO_IMAGE}. Response contained: ${partTypes.join(', ') || 'nothing'}`
|
||||
);
|
||||
throw new Error('No image data received from Gemini AI');
|
||||
}
|
||||
|
||||
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
|
||||
|
|
@ -312,38 +261,6 @@ export class ImageGenService {
|
|||
geminiParams,
|
||||
};
|
||||
} catch (error) {
|
||||
// Check for rate limit (HTTP 429)
|
||||
const err = error as { status?: number; message?: string };
|
||||
if (err.status === 429) {
|
||||
const geminiError = GeminiErrorDetector.classifyApiError(error);
|
||||
console.error(
|
||||
`[ImageGenService] Rate limit:`,
|
||||
GeminiErrorDetector.formatForLogging(geminiError)
|
||||
);
|
||||
throw new Error(geminiError.message);
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (error instanceof Error && error.message.includes('timed out')) {
|
||||
console.error(
|
||||
`[ImageGenService] Timeout after ${ImageGenService.GEMINI_TIMEOUT_MS}ms:`,
|
||||
error.message
|
||||
);
|
||||
throw new Error(
|
||||
`${ERROR_MESSAGES.GEMINI_TIMEOUT} after ${ImageGenService.GEMINI_TIMEOUT_MS / 1000} seconds`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for other API errors with status codes
|
||||
if (err.status) {
|
||||
const geminiError = GeminiErrorDetector.classifyApiError(error);
|
||||
console.error(
|
||||
`[ImageGenService] API error:`,
|
||||
GeminiErrorDetector.formatForLogging(geminiError)
|
||||
);
|
||||
throw new Error(geminiError.message);
|
||||
}
|
||||
|
||||
// Enhanced error detection with network diagnostics
|
||||
if (error instanceof Error) {
|
||||
// Classify the error and check for network issues (only on failure)
|
||||
|
|
@ -359,32 +276,6 @@ export class ImageGenService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a promise with timeout
|
||||
*/
|
||||
private async withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
operationName: string
|
||||
): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`${operationName} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await Promise.race([promise, timeoutPromise]);
|
||||
clearTimeout(timeoutId!);
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId!);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static validateReferenceImages(files: Express.Multer.File[]): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { StorageService, FileMetadata, UploadResult } from './StorageService';
|
|||
export class MinioStorageService implements StorageService {
|
||||
private client: MinioClient;
|
||||
private bucketName: string;
|
||||
private cdnBaseUrl: string;
|
||||
private publicUrl: string;
|
||||
|
||||
constructor(
|
||||
endpoint: string,
|
||||
|
|
@ -12,7 +12,7 @@ export class MinioStorageService implements StorageService {
|
|||
secretKey: string,
|
||||
useSSL: boolean = false,
|
||||
bucketName: string = 'banatie',
|
||||
cdnBaseUrl?: string,
|
||||
publicUrl?: string,
|
||||
) {
|
||||
// Parse endpoint to separate hostname and port
|
||||
const cleanEndpoint = endpoint.replace(/^https?:\/\//, '');
|
||||
|
|
@ -31,59 +31,119 @@ export class MinioStorageService implements StorageService {
|
|||
secretKey,
|
||||
});
|
||||
this.bucketName = bucketName;
|
||||
// CDN base URL without bucket name (e.g., https://cdn.banatie.app)
|
||||
this.cdnBaseUrl = cdnBaseUrl || process.env['CDN_BASE_URL'] || `${useSSL ? 'https' : 'http'}://${endpoint}/${bucketName}`;
|
||||
this.publicUrl = publicUrl || `${useSSL ? 'https' : 'http'}://${endpoint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file path in storage
|
||||
* Format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
*/
|
||||
private getFilePath(orgSlug: string, projectSlug: string, imageId: string): string {
|
||||
return `${orgSlug}/${projectSlug}/img/${imageId}`;
|
||||
private getFilePath(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): string {
|
||||
// Simplified path without date folder for now
|
||||
return `${orgId}/${projectId}/${category}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file extension from original filename
|
||||
*/
|
||||
private extractExtension(filename: string): string | undefined {
|
||||
if (!filename) return undefined;
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex <= 0) return undefined;
|
||||
return filename.substring(lastDotIndex + 1).toLowerCase();
|
||||
private generateUniqueFilename(originalFilename: string): string {
|
||||
// Sanitize filename first
|
||||
const sanitized = this.sanitizeFilename(originalFilename);
|
||||
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const ext = sanitized.includes('.') ? sanitized.substring(sanitized.lastIndexOf('.')) : '';
|
||||
const name = sanitized.includes('.')
|
||||
? sanitized.substring(0, sanitized.lastIndexOf('.'))
|
||||
: sanitized;
|
||||
|
||||
return `${name}-${timestamp}-${random}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate storage path components
|
||||
*/
|
||||
private validatePath(orgSlug: string, projectSlug: string, imageId: string): void {
|
||||
// Validate orgSlug
|
||||
if (!orgSlug || !/^[a-zA-Z0-9_-]+$/.test(orgSlug) || orgSlug.length > 50) {
|
||||
private sanitizeFilename(filename: string): string {
|
||||
// Remove path traversal attempts FIRST from entire filename
|
||||
let cleaned = filename.replace(/\.\./g, '').trim();
|
||||
|
||||
// Split filename and extension
|
||||
const lastDotIndex = cleaned.lastIndexOf('.');
|
||||
let baseName = lastDotIndex > 0 ? cleaned.substring(0, lastDotIndex) : cleaned;
|
||||
const extension = lastDotIndex > 0 ? cleaned.substring(lastDotIndex) : '';
|
||||
|
||||
// Remove dangerous characters from base name
|
||||
baseName = baseName
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
|
||||
.trim();
|
||||
|
||||
// Replace non-ASCII characters with ASCII equivalents or remove them
|
||||
// This prevents S3 signature mismatches with MinIO
|
||||
baseName = baseName
|
||||
.normalize('NFD') // Decompose combined characters (é -> e + ´)
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
|
||||
.replace(/[^\x20-\x7E]/g, '_') // Replace any remaining non-ASCII with underscore
|
||||
.replace(/[^\w\s\-_.]/g, '_') // Replace special chars (except word chars, space, dash, underscore, dot) with underscore
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.replace(/_{2,}/g, '_') // Collapse multiple underscores
|
||||
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
|
||||
|
||||
// Ensure we still have a valid base name
|
||||
if (baseName.length === 0) {
|
||||
baseName = 'file';
|
||||
}
|
||||
|
||||
// Sanitize extension (remove only dangerous chars, keep the dot)
|
||||
let sanitizedExt = extension
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
|
||||
.replace(/[^\x20-\x7E]/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
// Ensure extension starts with a dot and is reasonable
|
||||
if (sanitizedExt && !sanitizedExt.startsWith('.')) {
|
||||
sanitizedExt = '.' + sanitizedExt;
|
||||
}
|
||||
if (sanitizedExt.length > 10) {
|
||||
sanitizedExt = sanitizedExt.substring(0, 10);
|
||||
}
|
||||
|
||||
const result = baseName + sanitizedExt;
|
||||
return result.substring(0, 255); // Limit total length
|
||||
}
|
||||
|
||||
private validateFilePath(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: string,
|
||||
filename: string,
|
||||
): void {
|
||||
// Validate orgId
|
||||
if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) {
|
||||
throw new Error(
|
||||
'Invalid organization slug: must be alphanumeric with dashes/underscores, max 50 chars',
|
||||
'Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate projectSlug
|
||||
if (!projectSlug || !/^[a-zA-Z0-9_-]+$/.test(projectSlug) || projectSlug.length > 50) {
|
||||
// Validate projectId
|
||||
if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId) || projectId.length > 50) {
|
||||
throw new Error(
|
||||
'Invalid project slug: must be alphanumeric with dashes/underscores, max 50 chars',
|
||||
'Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate imageId (UUID format)
|
||||
if (!imageId || imageId.length === 0 || imageId.length > 50) {
|
||||
throw new Error('Invalid imageId: must be 1-50 characters');
|
||||
// Validate category
|
||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||
throw new Error('Invalid category: must be uploads, generated, or references');
|
||||
}
|
||||
|
||||
// Validate filename
|
||||
if (!filename || filename.length === 0 || filename.length > 255) {
|
||||
throw new Error('Invalid filename: must be 1-255 characters');
|
||||
}
|
||||
|
||||
// Check for path traversal and dangerous patterns
|
||||
if (imageId.includes('..') || imageId.includes('/') || imageId.includes('\\')) {
|
||||
throw new Error('Invalid characters in imageId: path traversal not allowed');
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw new Error('Invalid characters in filename: path traversal not allowed');
|
||||
}
|
||||
|
||||
// Prevent null bytes and control characters
|
||||
if (/[\x00-\x1f]/.test(imageId)) {
|
||||
throw new Error('Invalid imageId: control characters not allowed');
|
||||
if (/[\x00-\x1f]/.test(filename)) {
|
||||
throw new Error('Invalid filename: control characters not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,8 +154,8 @@ export class MinioStorageService implements StorageService {
|
|||
console.log(`Created bucket: ${this.bucketName}`);
|
||||
}
|
||||
|
||||
// Bucket should be public for CDN access (configured via mc anonymous set download)
|
||||
console.log(`Bucket ${this.bucketName} ready for CDN access`);
|
||||
// Note: With SNMD and presigned URLs, we don't need bucket policies
|
||||
console.log(`Bucket ${this.bucketName} ready for presigned URL access`);
|
||||
}
|
||||
|
||||
async bucketExists(): Promise<boolean> {
|
||||
|
|
@ -103,15 +163,15 @@ export class MinioStorageService implements StorageService {
|
|||
}
|
||||
|
||||
async uploadFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
originalFilename?: string,
|
||||
): Promise<UploadResult> {
|
||||
// Validate inputs first
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Buffer cannot be empty');
|
||||
|
|
@ -124,36 +184,26 @@ export class MinioStorageService implements StorageService {
|
|||
// Ensure bucket exists
|
||||
await this.createBucket();
|
||||
|
||||
// Get file path: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
|
||||
// Extract file extension from original filename
|
||||
const fileExtension = originalFilename ? this.extractExtension(originalFilename) : undefined;
|
||||
// Generate unique filename to avoid conflicts
|
||||
const uniqueFilename = this.generateUniqueFilename(filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename);
|
||||
|
||||
// Encode original filename to Base64 to safely store non-ASCII characters in metadata
|
||||
const originalNameEncoded = originalFilename
|
||||
? Buffer.from(originalFilename, 'utf-8').toString('base64')
|
||||
: undefined;
|
||||
const originalNameEncoded = Buffer.from(filename, 'utf-8').toString('base64');
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
const metadata = {
|
||||
'Content-Type': contentType,
|
||||
'X-Amz-Meta-Project': projectSlug,
|
||||
'X-Amz-Meta-Organization': orgSlug,
|
||||
'X-Amz-Meta-Original-Name': originalNameEncoded,
|
||||
'X-Amz-Meta-Original-Name-Encoding': 'base64',
|
||||
'X-Amz-Meta-Category': category,
|
||||
'X-Amz-Meta-Project': projectId,
|
||||
'X-Amz-Meta-Organization': orgId,
|
||||
'X-Amz-Meta-Upload-Time': new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (originalNameEncoded) {
|
||||
metadata['X-Amz-Meta-Original-Name'] = originalNameEncoded;
|
||||
metadata['X-Amz-Meta-Original-Name-Encoding'] = 'base64';
|
||||
}
|
||||
console.log(`Uploading file to: ${this.bucketName}/${filePath}`);
|
||||
|
||||
if (fileExtension) {
|
||||
metadata['X-Amz-Meta-File-Extension'] = fileExtension;
|
||||
}
|
||||
|
||||
console.log(`[MinIO] Uploading file to: ${this.bucketName}/${filePath}`);
|
||||
|
||||
await this.client.putObject(
|
||||
const result = await this.client.putObject(
|
||||
this.bucketName,
|
||||
filePath,
|
||||
buffer,
|
||||
|
|
@ -161,29 +211,28 @@ export class MinioStorageService implements StorageService {
|
|||
metadata,
|
||||
);
|
||||
|
||||
const url = this.getPublicUrl(orgSlug, projectSlug, imageId);
|
||||
const url = this.getPublicUrl(orgId, projectId, category, uniqueFilename);
|
||||
|
||||
console.log(`[MinIO] CDN URL: ${url}`);
|
||||
console.log(`Generated API URL: ${url}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filename: imageId,
|
||||
filename: uniqueFilename,
|
||||
path: filePath,
|
||||
url,
|
||||
size: buffer.length,
|
||||
contentType,
|
||||
...(originalFilename && { originalFilename }),
|
||||
...(fileExtension && { fileExtension }),
|
||||
};
|
||||
}
|
||||
|
||||
async downloadFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<Buffer> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
|
||||
const stream = await this.client.getObject(this.bucketName, filePath);
|
||||
|
||||
|
|
@ -196,91 +245,184 @@ export class MinioStorageService implements StorageService {
|
|||
}
|
||||
|
||||
async streamFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<import('stream').Readable> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
|
||||
// Return the stream directly without buffering - memory efficient!
|
||||
return await this.client.getObject(this.bucketName, filePath);
|
||||
}
|
||||
|
||||
async deleteFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
await this.client.removeObject(this.bucketName, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public CDN URL for file access
|
||||
* Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId}
|
||||
*/
|
||||
getPublicUrl(orgSlug: string, projectSlug: string, imageId: string): string {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
return `${this.cdnBaseUrl}/${filePath}`;
|
||||
getPublicUrl(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): string {
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
// Production-ready: Return API URL for presigned URL access
|
||||
const apiBaseUrl = process.env['API_BASE_URL'] || 'http://localhost:3000';
|
||||
return `${apiBaseUrl}/api/images/${orgId}/${projectId}/${category}/${filename}`;
|
||||
}
|
||||
|
||||
async getPresignedUploadUrl(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
expirySeconds: number,
|
||||
contentType: string,
|
||||
): Promise<string> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
|
||||
if (!contentType || contentType.trim().length === 0) {
|
||||
throw new Error('Content type is required for presigned upload URL');
|
||||
}
|
||||
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds);
|
||||
}
|
||||
|
||||
async getPresignedDownloadUrl(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
expirySeconds: number = 86400, // 24 hours default
|
||||
): Promise<string> {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
const presignedUrl = await this.client.presignedGetObject(
|
||||
this.bucketName,
|
||||
filePath,
|
||||
expirySeconds,
|
||||
);
|
||||
|
||||
// Replace internal Docker hostname with CDN URL if configured
|
||||
if (this.cdnBaseUrl) {
|
||||
// Access protected properties via type assertion for URL replacement
|
||||
const client = this.client as unknown as { host: string; port: number; protocol: string };
|
||||
const clientEndpoint = client.host + (client.port ? `:${client.port}` : '');
|
||||
// Replace internal Docker hostname with public URL if configured
|
||||
if (this.publicUrl) {
|
||||
const clientEndpoint = this.client.host + (this.client.port ? `:${this.client.port}` : '');
|
||||
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, '');
|
||||
|
||||
return presignedUrl.replace(`${client.protocol}//${clientEndpoint}/${this.bucketName}`, this.cdnBaseUrl);
|
||||
return presignedUrl.replace(`${this.client.protocol}//${clientEndpoint}`, this.publicUrl);
|
||||
}
|
||||
|
||||
return presignedUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a project's img folder
|
||||
*/
|
||||
async listProjectFiles(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category?: 'uploads' | 'generated' | 'references',
|
||||
): Promise<FileMetadata[]> {
|
||||
const prefix = category ? `${orgId}/${projectId}/${category}/` : `${orgId}/${projectId}/`;
|
||||
|
||||
const files: FileMetadata[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = this.client.listObjects(this.bucketName, prefix, true);
|
||||
|
||||
stream.on('data', async (obj) => {
|
||||
try {
|
||||
if (!obj.name) return;
|
||||
|
||||
const metadata = await this.client.statObject(this.bucketName, obj.name);
|
||||
|
||||
const pathParts = obj.name.split('/');
|
||||
const filename = pathParts[pathParts.length - 1];
|
||||
const categoryFromPath = pathParts[2] as 'uploads' | 'generated' | 'references';
|
||||
|
||||
if (!filename || !categoryFromPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
files.push({
|
||||
key: `${this.bucketName}/${obj.name}`,
|
||||
filename,
|
||||
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
|
||||
size: obj.size || 0,
|
||||
url: this.getPublicUrl(orgId, projectId, categoryFromPath, filename),
|
||||
createdAt: obj.lastModified || new Date(),
|
||||
});
|
||||
} catch (error) {}
|
||||
});
|
||||
|
||||
stream.on('end', () => resolve(files));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
parseKey(key: string): {
|
||||
orgId: string;
|
||||
projectId: string;
|
||||
category: 'uploads' | 'generated' | 'references';
|
||||
filename: string;
|
||||
} | null {
|
||||
try {
|
||||
const match = key.match(
|
||||
/^banatie\/([^/]+)\/([^/]+)\/(uploads|generated|references)\/[^/]+\/(.+)$/,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, orgId, projectId, category, filename] = match;
|
||||
|
||||
if (!orgId || !projectId || !category || !filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
orgId,
|
||||
projectId,
|
||||
category: category as 'uploads' | 'generated' | 'references',
|
||||
filename,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fileExists(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
await this.client.statObject(this.bucketName, filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
prefix?: string,
|
||||
): Promise<FileMetadata[]> {
|
||||
this.validatePath(orgSlug, projectSlug, 'dummy');
|
||||
this.validateFilePath(orgId, projectId, category, 'dummy.txt');
|
||||
|
||||
const basePath = `${orgSlug}/${projectSlug}/img/`;
|
||||
const basePath = `${orgId}/${projectId}/${category}/`;
|
||||
const searchPrefix = prefix ? `${basePath}${prefix}` : basePath;
|
||||
|
||||
const files: FileMetadata[] = [];
|
||||
|
|
@ -288,22 +430,31 @@ export class MinioStorageService implements StorageService {
|
|||
return new Promise((resolve, reject) => {
|
||||
const stream = this.client.listObjects(this.bucketName, searchPrefix, true);
|
||||
|
||||
stream.on('data', async (obj) => {
|
||||
stream.on('data', (obj) => {
|
||||
if (!obj.name || !obj.size) return;
|
||||
|
||||
try {
|
||||
const pathParts = obj.name.split('/');
|
||||
const imageId = pathParts[pathParts.length - 1];
|
||||
const filename = pathParts[pathParts.length - 1];
|
||||
|
||||
if (!imageId) return;
|
||||
if (!filename) return;
|
||||
|
||||
// Get metadata to find content type (no extension in filename)
|
||||
const metadata = await this.client.statObject(this.bucketName, obj.name);
|
||||
// Infer content type from file extension (more efficient than statObject)
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
const contentType =
|
||||
{
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
}[ext || ''] || 'application/octet-stream';
|
||||
|
||||
files.push({
|
||||
filename: imageId!,
|
||||
filename,
|
||||
size: obj.size,
|
||||
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
|
||||
contentType,
|
||||
lastModified: obj.lastModified || new Date(),
|
||||
etag: obj.etag || '',
|
||||
path: obj.name,
|
||||
|
|
@ -323,52 +474,4 @@ export class MinioStorageService implements StorageService {
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse storage key to extract components
|
||||
* Format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
*/
|
||||
parseKey(key: string): {
|
||||
orgSlug: string;
|
||||
projectSlug: string;
|
||||
imageId: string;
|
||||
} | null {
|
||||
try {
|
||||
// Match: orgSlug/projectSlug/img/imageId
|
||||
const match = key.match(/^([^/]+)\/([^/]+)\/img\/([^/]+)$/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, orgSlug, projectSlug, imageId] = match;
|
||||
|
||||
if (!orgSlug || !projectSlug || !imageId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
imageId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fileExists(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
this.validatePath(orgSlug, projectSlug, imageId);
|
||||
const filePath = this.getFilePath(orgSlug, projectSlug, imageId);
|
||||
await this.client.statObject(this.bucketName, filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,11 @@ export interface FileMetadata {
|
|||
|
||||
export interface UploadResult {
|
||||
success: boolean;
|
||||
filename: string; // UUID (same as image.id)
|
||||
filename: string;
|
||||
path: string;
|
||||
url: string; // CDN URL for accessing the file
|
||||
url: string; // API URL for accessing the file
|
||||
size: number;
|
||||
contentType: string;
|
||||
originalFilename?: string; // User's original filename
|
||||
fileExtension?: string; // Original extension (png, jpg, etc.)
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
@ -34,125 +32,123 @@ export interface StorageService {
|
|||
|
||||
/**
|
||||
* Upload a file to storage
|
||||
* Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
*
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID for the file (same as image.id in DB)
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category (uploads, generated, references)
|
||||
* @param filename Original filename
|
||||
* @param buffer File buffer
|
||||
* @param contentType MIME type
|
||||
* @param originalFilename Original filename from user (for metadata)
|
||||
*/
|
||||
uploadFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
originalFilename?: string,
|
||||
): Promise<UploadResult>;
|
||||
|
||||
/**
|
||||
* Download a file from storage
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename to download
|
||||
*/
|
||||
downloadFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<Buffer>;
|
||||
|
||||
/**
|
||||
* Stream a file from storage (memory efficient)
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename to stream
|
||||
*/
|
||||
streamFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<Readable>;
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for downloading a file
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename
|
||||
* @param expirySeconds URL expiry time in seconds
|
||||
*/
|
||||
getPresignedDownloadUrl(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
expirySeconds: number,
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for uploading a file
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename
|
||||
* @param expirySeconds URL expiry time in seconds
|
||||
* @param contentType MIME type
|
||||
*/
|
||||
getPresignedUploadUrl(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
expirySeconds: number,
|
||||
contentType: string,
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* List files in a project's img folder
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* List files in a specific path
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param prefix Optional prefix to filter files
|
||||
*/
|
||||
listFiles(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
prefix?: string,
|
||||
): Promise<FileMetadata[]>;
|
||||
|
||||
/**
|
||||
* Delete a file from storage
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename to delete
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename to delete
|
||||
*/
|
||||
deleteFile(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename to check
|
||||
* @param orgId Organization ID
|
||||
* @param projectId Project ID
|
||||
* @param category File category
|
||||
* @param filename Filename to check
|
||||
*/
|
||||
fileExists(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
filename: string,
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get the public CDN URL for a file
|
||||
* Returns: https://cdn.banatie.app/{orgSlug}/{projectSlug}/img/{imageId}
|
||||
*
|
||||
* @param orgSlug Organization slug
|
||||
* @param projectSlug Project slug
|
||||
* @param imageId UUID filename
|
||||
*/
|
||||
getPublicUrl(
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
imageId: string,
|
||||
): string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export class AliasService {
|
|||
});
|
||||
}
|
||||
|
||||
async validateAliasForAssignment(alias: string, _projectId: string, _flowId?: string): Promise<void> {
|
||||
async validateAliasForAssignment(alias: string, projectId: string, flowId?: string): Promise<void> {
|
||||
const formatResult = validateAliasFormat(alias);
|
||||
if (!formatResult.valid) {
|
||||
throw new Error(formatResult.error!.message);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import { eq, desc, count, and, isNull, inArray } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { generations, flows, images, projects } from '@banatie/database';
|
||||
import { generations, flows, images } from '@banatie/database';
|
||||
import type {
|
||||
Generation,
|
||||
NewGeneration,
|
||||
|
|
@ -20,8 +20,6 @@ import type { ReferenceImage } from '@/types/api';
|
|||
export interface CreateGenerationParams {
|
||||
projectId: string;
|
||||
apiKeyId: string;
|
||||
organizationSlug: string; // For storage paths (orgSlug/projectSlug/category/file)
|
||||
projectSlug: string; // For storage paths
|
||||
prompt: string;
|
||||
referenceImages?: string[] | undefined; // Aliases to resolve
|
||||
aspectRatio?: string | undefined;
|
||||
|
|
@ -148,16 +146,13 @@ export class GenerationService {
|
|||
.where(eq(generations.id, generation.id));
|
||||
}
|
||||
|
||||
// Generate imageId (UUID) upfront - this will be the filename in storage
|
||||
const imageId = randomUUID();
|
||||
|
||||
const genResult = await this.imageGenService.generateImage({
|
||||
prompt: usedPrompt, // Use the prompt that was stored (enhanced or original)
|
||||
imageId, // UUID used as filename: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
filename: `gen_${generation.id}`,
|
||||
referenceImages: referenceImageBuffers,
|
||||
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
orgSlug: params.organizationSlug,
|
||||
projectSlug: params.projectSlug,
|
||||
orgId: 'default',
|
||||
projectId: params.projectId,
|
||||
meta: params.meta || {},
|
||||
});
|
||||
|
||||
|
|
@ -175,14 +170,13 @@ export class GenerationService {
|
|||
const fileHash = null;
|
||||
|
||||
const imageRecord = await this.imageService.create({
|
||||
id: imageId, // Use the same UUID for image record
|
||||
projectId: params.projectId,
|
||||
flowId: finalFlowId,
|
||||
generationId: generation.id,
|
||||
apiKeyId: params.apiKeyId,
|
||||
storageKey,
|
||||
storageUrl: genResult.url!,
|
||||
mimeType: genResult.generatedImageData?.mimeType || 'image/png',
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: genResult.size || 0,
|
||||
fileHash,
|
||||
source: 'generated',
|
||||
|
|
@ -190,8 +184,6 @@ export class GenerationService {
|
|||
meta: params.meta || {},
|
||||
width: genResult.generatedImageData?.width ?? null,
|
||||
height: genResult.generatedImageData?.height ?? null,
|
||||
originalFilename: `generated-image.${genResult.generatedImageData?.fileExtension || 'png'}`,
|
||||
fileExtension: genResult.generatedImageData?.fileExtension || 'png',
|
||||
});
|
||||
|
||||
// Reassign project alias if provided (override behavior per Section 5.2)
|
||||
|
|
@ -278,22 +270,27 @@ export class GenerationService {
|
|||
throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`);
|
||||
}
|
||||
|
||||
// Parse storage key: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
const parts = resolution.image.storageKey.split('/');
|
||||
if (parts.length < 4 || parts[2] !== 'img') {
|
||||
if (parts.length < 4) {
|
||||
throw new Error(`Invalid storage key format: ${resolution.image.storageKey}`);
|
||||
}
|
||||
|
||||
const orgSlug = parts[0]!;
|
||||
const projectSlug = parts[1]!;
|
||||
const imageId = parts[3]!;
|
||||
const orgId = parts[0]!;
|
||||
const projId = parts[1]!;
|
||||
const category = parts[2]! as 'uploads' | 'generated' | 'references';
|
||||
const filename = parts.slice(3).join('/');
|
||||
|
||||
const buffer = await storageService.downloadFile(orgSlug, projectSlug, imageId);
|
||||
const buffer = await storageService.downloadFile(
|
||||
orgId,
|
||||
projId,
|
||||
category,
|
||||
filename
|
||||
);
|
||||
|
||||
buffers.push({
|
||||
buffer,
|
||||
mimetype: resolution.image.mimeType,
|
||||
originalname: resolution.image.originalFilename || imageId,
|
||||
originalname: filename,
|
||||
});
|
||||
|
||||
metadata.push({
|
||||
|
|
@ -380,27 +377,6 @@ export class GenerationService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization and project slugs for storage paths
|
||||
*/
|
||||
private async getSlugs(projectId: string): Promise<{ orgSlug: string; projectSlug: string }> {
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(projects.id, projectId),
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
return {
|
||||
orgSlug: project.organization.slug,
|
||||
projectSlug: project.slug,
|
||||
};
|
||||
}
|
||||
|
||||
private async updateStatus(
|
||||
id: string,
|
||||
status: 'pending' | 'processing' | 'success' | 'failed',
|
||||
|
|
@ -515,21 +491,14 @@ export class GenerationService {
|
|||
// Update status to processing
|
||||
await this.updateStatus(id, 'processing');
|
||||
|
||||
// Get slugs for storage paths
|
||||
const { orgSlug, projectSlug } = await this.getSlugs(generation.projectId);
|
||||
|
||||
// Use the existing output image ID as the imageId for storage
|
||||
// This ensures the file is overwritten at the same path
|
||||
const imageId = generation.outputImageId;
|
||||
|
||||
// Use EXACT same parameters as original (no overrides)
|
||||
const genResult = await this.imageGenService.generateImage({
|
||||
prompt: generation.prompt,
|
||||
imageId, // Same UUID to overwrite existing file
|
||||
filename: `gen_${id}`,
|
||||
referenceImages: [], // TODO: Re-resolve referenced images if needed
|
||||
aspectRatio: generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
orgId: 'default',
|
||||
projectId: generation.projectId,
|
||||
meta: generation.meta as Record<string, unknown> || {},
|
||||
});
|
||||
|
||||
|
|
@ -563,7 +532,7 @@ export class GenerationService {
|
|||
}
|
||||
|
||||
// Keep retry() for backward compatibility, delegate to regenerate()
|
||||
async retry(id: string, _overrides?: { prompt?: string; aspectRatio?: string }): Promise<GenerationWithRelations> {
|
||||
async retry(id: string, overrides?: { prompt?: string; aspectRatio?: string }): Promise<GenerationWithRelations> {
|
||||
// Ignore overrides, regenerate with original parameters
|
||||
return await this.regenerate(id);
|
||||
}
|
||||
|
|
@ -636,20 +605,14 @@ export class GenerationService {
|
|||
const promptToUse = updates.prompt || generation.prompt;
|
||||
const aspectRatioToUse = updates.aspectRatio || generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO;
|
||||
|
||||
// Get slugs for storage paths
|
||||
const { orgSlug, projectSlug } = await this.getSlugs(generation.projectId);
|
||||
|
||||
// Use the existing output image ID as the imageId for storage
|
||||
const imageId = generation.outputImageId!;
|
||||
|
||||
// Regenerate image
|
||||
const genResult = await this.imageGenService.generateImage({
|
||||
prompt: promptToUse,
|
||||
imageId, // Same UUID to overwrite existing file
|
||||
filename: `gen_${id}`,
|
||||
referenceImages: [],
|
||||
aspectRatio: aspectRatioToUse,
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
orgId: 'default',
|
||||
projectId: generation.projectId,
|
||||
meta: updates.meta || generation.meta || {},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -154,16 +154,16 @@ export class ImageService {
|
|||
|
||||
try {
|
||||
// 1. Delete physical file from MinIO storage
|
||||
// Storage key format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
const storageParts = image.storageKey.split('/');
|
||||
|
||||
if (storageParts.length >= 4 && storageParts[2] === 'img') {
|
||||
const orgSlug = storageParts[0]!;
|
||||
const projectSlug = storageParts[1]!;
|
||||
const imageId = storageParts[3]!;
|
||||
if (storageParts.length >= 4) {
|
||||
const orgId = storageParts[0]!;
|
||||
const projectId = storageParts[1]!;
|
||||
const category = storageParts[2]! as 'uploads' | 'generated' | 'references';
|
||||
const filename = storageParts.slice(3).join('/');
|
||||
|
||||
await storageService.deleteFile(orgSlug, projectSlug, imageId);
|
||||
await storageService.deleteFile(orgId, projectId, category, filename);
|
||||
}
|
||||
|
||||
// 2. Cascade: Set outputImageId = NULL in related generations
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@ export interface GenerateImageRequestWithFiles extends Request {
|
|||
// Image generation service types
|
||||
export interface ImageGenerationOptions {
|
||||
prompt: string;
|
||||
imageId: string; // UUID used as filename in storage (same as image.id in DB)
|
||||
filename: string;
|
||||
referenceImages?: ReferenceImage[];
|
||||
aspectRatio?: string;
|
||||
orgSlug?: string;
|
||||
projectSlug?: string;
|
||||
orgId?: string;
|
||||
projectId?: string;
|
||||
userId?: string;
|
||||
meta?: {
|
||||
tags?: string[];
|
||||
|
|
@ -91,15 +91,14 @@ export interface GeminiParams {
|
|||
|
||||
export interface ImageGenerationResult {
|
||||
success: boolean;
|
||||
imageId?: string; // UUID filename (same as image.id in DB)
|
||||
filename?: string;
|
||||
filepath?: string;
|
||||
url?: string; // CDN URL for accessing the image
|
||||
url?: string; // API URL for accessing the image
|
||||
size?: number; // File size in bytes
|
||||
description?: string;
|
||||
model: string;
|
||||
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
|
||||
error?: string;
|
||||
errorCode?: string; // Gemini-specific error code (GEMINI_RATE_LIMIT, GEMINI_TIMEOUT, etc.)
|
||||
errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors
|
||||
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,298 +0,0 @@
|
|||
import { ERROR_CODES, ERROR_MESSAGES } from './constants/errors';
|
||||
|
||||
/**
|
||||
* Result of Gemini error analysis
|
||||
*/
|
||||
export interface GeminiErrorResult {
|
||||
code: string;
|
||||
message: string;
|
||||
finishReason?: string | undefined;
|
||||
blockReason?: string | undefined;
|
||||
safetyCategories?: string[] | undefined;
|
||||
retryAfter?: number | undefined;
|
||||
httpStatus?: number | undefined;
|
||||
technicalDetails?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety rating from Gemini response
|
||||
*/
|
||||
interface SafetyRating {
|
||||
category?: string;
|
||||
probability?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini response structure (partial)
|
||||
*/
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{
|
||||
finishReason?: string;
|
||||
finishMessage?: string;
|
||||
content?: {
|
||||
parts?: Array<{
|
||||
text?: string;
|
||||
inlineData?: { data?: string; mimeType?: string };
|
||||
}>;
|
||||
};
|
||||
safetyRatings?: SafetyRating[];
|
||||
}>;
|
||||
promptFeedback?: {
|
||||
blockReason?: string;
|
||||
blockReasonMessage?: string;
|
||||
safetyRatings?: SafetyRating[];
|
||||
};
|
||||
usageMetadata?: {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector for Gemini AI specific errors
|
||||
* Provides detailed error classification for rate limits, safety blocks, timeouts, etc.
|
||||
*/
|
||||
export class GeminiErrorDetector {
|
||||
/**
|
||||
* Classify an API-level error (HTTP errors from Gemini)
|
||||
*/
|
||||
static classifyApiError(error: unknown): GeminiErrorResult {
|
||||
const err = error as { status?: number; message?: string; details?: unknown };
|
||||
|
||||
// Check for rate limit (HTTP 429)
|
||||
if (err.status === 429) {
|
||||
const retryAfter = this.extractRetryAfter(error);
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_RATE_LIMIT,
|
||||
message: retryAfter
|
||||
? `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Retry after ${retryAfter} seconds.`
|
||||
: `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Please wait before retrying.`,
|
||||
httpStatus: 429,
|
||||
retryAfter,
|
||||
technicalDetails: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for authentication errors
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
||||
message: 'Gemini API authentication failed. Check API key.',
|
||||
httpStatus: err.status,
|
||||
technicalDetails: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for server errors
|
||||
if (err.status === 500 || err.status === 503) {
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
||||
message: 'Gemini API service temporarily unavailable.',
|
||||
httpStatus: err.status,
|
||||
technicalDetails: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for bad request
|
||||
if (err.status === 400) {
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
||||
message: `Gemini API invalid request: ${err.message || 'Unknown error'}`,
|
||||
httpStatus: 400,
|
||||
technicalDetails: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Generic API error
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
||||
message: err.message || ERROR_MESSAGES.GEMINI_API_ERROR,
|
||||
httpStatus: err.status,
|
||||
technicalDetails: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a Gemini response for errors (finishReason, blockReason)
|
||||
* Returns null if no error detected
|
||||
*/
|
||||
static analyzeResponse(response: GeminiResponse): GeminiErrorResult | null {
|
||||
// Check promptFeedback for blocked prompts
|
||||
if (response.promptFeedback?.blockReason) {
|
||||
const safetyCategories = this.extractSafetyCategories(
|
||||
response.promptFeedback.safetyRatings
|
||||
);
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
||||
message:
|
||||
response.promptFeedback.blockReasonMessage ||
|
||||
`Prompt blocked: ${response.promptFeedback.blockReason}`,
|
||||
blockReason: response.promptFeedback.blockReason,
|
||||
safetyCategories,
|
||||
technicalDetails: `blockReason: ${response.promptFeedback.blockReason}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check candidate finishReason
|
||||
const candidate = response.candidates?.[0];
|
||||
if (!candidate) {
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_NO_IMAGE,
|
||||
message: 'No response candidates from Gemini AI.',
|
||||
technicalDetails: 'response.candidates is empty or undefined',
|
||||
};
|
||||
}
|
||||
|
||||
const finishReason = candidate.finishReason;
|
||||
|
||||
// STOP is normal completion
|
||||
if (!finishReason || finishReason === 'STOP') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle different finishReasons
|
||||
switch (finishReason) {
|
||||
case 'SAFETY':
|
||||
case 'IMAGE_SAFETY': {
|
||||
const safetyCategories = this.extractSafetyCategories(candidate.safetyRatings);
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_SAFETY_BLOCK,
|
||||
message: `Content blocked due to safety: ${safetyCategories.join(', ') || 'unspecified'}`,
|
||||
finishReason,
|
||||
safetyCategories,
|
||||
technicalDetails: `finishReason: ${finishReason}, safetyRatings: ${JSON.stringify(candidate.safetyRatings)}`,
|
||||
};
|
||||
}
|
||||
|
||||
case 'NO_IMAGE':
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_NO_IMAGE,
|
||||
message: 'Gemini AI could not generate an image for this prompt. Try rephrasing.',
|
||||
finishReason,
|
||||
technicalDetails: `finishReason: ${finishReason}`,
|
||||
};
|
||||
|
||||
case 'IMAGE_PROHIBITED_CONTENT':
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
||||
message: 'Image generation blocked due to prohibited content in prompt.',
|
||||
finishReason,
|
||||
technicalDetails: `finishReason: ${finishReason}`,
|
||||
};
|
||||
|
||||
case 'MAX_TOKENS':
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
||||
message: 'Response exceeded maximum token limit. Try a shorter prompt.',
|
||||
finishReason,
|
||||
technicalDetails: `finishReason: ${finishReason}`,
|
||||
};
|
||||
|
||||
case 'RECITATION':
|
||||
case 'IMAGE_RECITATION':
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
||||
message: 'Response blocked due to potential copyright concerns.',
|
||||
finishReason,
|
||||
technicalDetails: `finishReason: ${finishReason}`,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
||||
message: `Generation stopped unexpectedly: ${finishReason}`,
|
||||
finishReason,
|
||||
technicalDetails: `finishReason: ${finishReason}, finishMessage: ${candidate.finishMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response has image data
|
||||
*/
|
||||
static hasImageData(response: GeminiResponse): boolean {
|
||||
const parts = response.candidates?.[0]?.content?.parts;
|
||||
if (!parts) return false;
|
||||
return parts.some((part) => part.inlineData?.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error result for logging
|
||||
*/
|
||||
static formatForLogging(result: GeminiErrorResult): string {
|
||||
const parts = [`[${result.code}] ${result.message}`];
|
||||
|
||||
if (result.finishReason) {
|
||||
parts.push(`finishReason=${result.finishReason}`);
|
||||
}
|
||||
if (result.blockReason) {
|
||||
parts.push(`blockReason=${result.blockReason}`);
|
||||
}
|
||||
if (result.httpStatus) {
|
||||
parts.push(`httpStatus=${result.httpStatus}`);
|
||||
}
|
||||
if (result.retryAfter) {
|
||||
parts.push(`retryAfter=${result.retryAfter}s`);
|
||||
}
|
||||
if (result.safetyCategories?.length) {
|
||||
parts.push(`safety=[${result.safetyCategories.join(', ')}]`);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log Gemini response structure for debugging
|
||||
*/
|
||||
static logResponseStructure(response: GeminiResponse, prefix: string = ''): void {
|
||||
const parts = response.candidates?.[0]?.content?.parts || [];
|
||||
const partTypes = parts.map((p) => {
|
||||
if (p.inlineData) return 'image';
|
||||
if (p.text) return 'text';
|
||||
return 'other';
|
||||
});
|
||||
|
||||
console.log(`[ImageGenService]${prefix ? ` [${prefix}]` : ''} Gemini response:`, {
|
||||
hasCandidates: !!response.candidates?.length,
|
||||
candidateCount: response.candidates?.length || 0,
|
||||
finishReason: response.candidates?.[0]?.finishReason || null,
|
||||
blockReason: response.promptFeedback?.blockReason || null,
|
||||
partsCount: parts.length,
|
||||
partTypes,
|
||||
usageMetadata: response.usageMetadata || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract retry-after value from error
|
||||
*/
|
||||
private static extractRetryAfter(error: unknown): number | undefined {
|
||||
const err = error as { headers?: { get?: (key: string) => string | null } };
|
||||
|
||||
// Try to get from headers
|
||||
if (err.headers?.get) {
|
||||
const retryAfter = err.headers.get('retry-after');
|
||||
if (retryAfter) {
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds)) return seconds;
|
||||
}
|
||||
}
|
||||
|
||||
// Default retry after for rate limits
|
||||
return 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract safety category names from ratings
|
||||
*/
|
||||
private static extractSafetyCategories(ratings?: SafetyRating[]): string[] {
|
||||
if (!ratings || ratings.length === 0) return [];
|
||||
|
||||
// Filter for high/medium probability ratings and extract category names
|
||||
return ratings
|
||||
.filter((r) => r.probability === 'HIGH' || r.probability === 'MEDIUM')
|
||||
.map((r) => r.category?.replace('HARM_CATEGORY_', '') || 'UNKNOWN')
|
||||
.filter((c) => c !== 'UNKNOWN');
|
||||
}
|
||||
}
|
||||
|
|
@ -51,14 +51,6 @@ export const ERROR_MESSAGES = {
|
|||
INTERNAL_SERVER_ERROR: 'Internal server error',
|
||||
INVALID_REQUEST: 'Invalid request',
|
||||
OPERATION_FAILED: 'Operation failed',
|
||||
|
||||
// Gemini AI Errors
|
||||
GEMINI_RATE_LIMIT: 'Gemini API rate limit exceeded',
|
||||
GEMINI_CONTENT_BLOCKED: 'Content blocked by Gemini safety filters',
|
||||
GEMINI_TIMEOUT: 'Gemini API request timed out',
|
||||
GEMINI_NO_IMAGE: 'Gemini AI could not generate image',
|
||||
GEMINI_SAFETY_BLOCK: 'Content blocked due to safety concerns',
|
||||
GEMINI_API_ERROR: 'Gemini API returned an error',
|
||||
} as const;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
|
@ -117,14 +109,6 @@ export const ERROR_CODES = {
|
|||
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||
INVALID_REQUEST: 'INVALID_REQUEST',
|
||||
OPERATION_FAILED: 'OPERATION_FAILED',
|
||||
|
||||
// Gemini AI Errors
|
||||
GEMINI_RATE_LIMIT: 'GEMINI_RATE_LIMIT',
|
||||
GEMINI_CONTENT_BLOCKED: 'GEMINI_CONTENT_BLOCKED',
|
||||
GEMINI_TIMEOUT: 'GEMINI_TIMEOUT',
|
||||
GEMINI_NO_IMAGE: 'GEMINI_NO_IMAGE',
|
||||
GEMINI_SAFETY_BLOCK: 'GEMINI_SAFETY_BLOCK',
|
||||
GEMINI_API_ERROR: 'GEMINI_API_ERROR',
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export const IMAGE_LIMITS = {
|
|||
export const GENERATION_LIMITS = {
|
||||
MAX_PROMPT_LENGTH: 5000,
|
||||
MAX_RETRY_COUNT: 3,
|
||||
DEFAULT_ASPECT_RATIO: '16:9',
|
||||
DEFAULT_ASPECT_RATIO: '1:1',
|
||||
ALLOWED_ASPECT_RATIOS: ['1:1', '16:9', '9:16', '3:2', '2:3', '4:3', '3:4'] as const,
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
# Landing App Environment Variables
|
||||
|
||||
# Waitlist logs path (absolute path required)
|
||||
# Development: use local directory
|
||||
# Production: use Docker volume path
|
||||
WAITLIST_LOGS_PATH=/absolute/path/to/waitlist-logs
|
||||
|
|
@ -32,10 +32,6 @@ yarn-error.log*
|
|||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# waitlist logs
|
||||
/waitlist-logs/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
|
|||
|
|
@ -1,39 +1,90 @@
|
|||
# Simplified Dockerfile for Next.js Landing Page
|
||||
# Multi-stage Dockerfile for Next.js Landing Page
|
||||
|
||||
# Stage 1: Dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
|
||||
# Copy workspace configuration
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
|
||||
# Copy landing package.json
|
||||
COPY apps/landing/package.json ./apps/landing/
|
||||
|
||||
# Copy database package (workspace dependency)
|
||||
COPY packages/database/package.json ./packages/database/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
|
||||
# Copy everything
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/landing/node_modules ./apps/landing/node_modules
|
||||
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||
|
||||
# Copy workspace files
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY apps/landing ./apps/landing
|
||||
|
||||
# Copy database package
|
||||
COPY packages/database ./packages/database
|
||||
|
||||
# Install and build
|
||||
RUN pnpm install --frozen-lockfile
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm --filter @banatie/landing build
|
||||
# Copy landing app
|
||||
COPY apps/landing ./apps/landing
|
||||
|
||||
# Production runner
|
||||
# Set working directory to landing
|
||||
WORKDIR /app/apps/landing
|
||||
|
||||
# Build Next.js application
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 3: Production Runner
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built app
|
||||
COPY --from=builder /app/apps/landing/.next/standalone ./
|
||||
COPY --from=builder /app/apps/landing/.next/static ./apps/landing/.next/static
|
||||
# Copy workspace configuration
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
|
||||
# Copy database package
|
||||
COPY --from=builder /app/packages/database ./packages/database
|
||||
|
||||
# Copy built Next.js application
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/landing/.next ./apps/landing/.next
|
||||
COPY --from=builder /app/apps/landing/package.json ./apps/landing/
|
||||
COPY --from=builder /app/apps/landing/public ./apps/landing/public
|
||||
|
||||
# Copy node_modules for runtime
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps/landing/node_modules ./apps/landing/node_modules
|
||||
COPY --from=builder /app/packages/database/node_modules ./packages/database/node_modules
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
WORKDIR /app/apps/landing
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
|
|
@ -2,12 +2,8 @@ import type { NextConfig } from 'next';
|
|||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,16 +5,25 @@
|
|||
"scripts": {
|
||||
"dev": "next dev -p 3010",
|
||||
"build": "next build",
|
||||
"postbuild": "cp -r .next/static .next/standalone/apps/landing/.next/ && cp -r public .next/standalone/apps/landing/",
|
||||
"start": "node .next/standalone/apps/landing/server.js",
|
||||
"start": "next start",
|
||||
"deploy": "cp -r out/* /var/www/banatie.app/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@banatie/database": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "15.5.9",
|
||||
"next": "15.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
@ -22,6 +31,7 @@
|
|||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 907 B |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 920 KiB |
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"name": "Banatie - AI Image Generation API",
|
||||
"short_name": "Banatie",
|
||||
"description": "AI-powered image generation API with built-in CDN delivery",
|
||||
"icons": [
|
||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#a855f7",
|
||||
"background_color": "#0f172a",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "developer tools"]
|
||||
}
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { Table } from '@/components/docs/shared/Table';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
||||
import {
|
||||
Hero,
|
||||
SectionHeader,
|
||||
InlineCode,
|
||||
EndpointCard,
|
||||
ResponseBlock,
|
||||
} from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['api-flows'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'API Reference', path: '/docs/api/' },
|
||||
{ name: 'Flows', path: '/docs/api/flows/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'overview', text: 'Overview', level: 2 },
|
||||
{ id: 'list-flows', text: 'List Flows', level: 2 },
|
||||
{ id: 'get-flow', text: 'Get Flow', level: 2 },
|
||||
{ id: 'list-flow-generations', text: 'List Flow Generations', level: 2 },
|
||||
{ id: 'list-flow-images', text: 'List Flow Images', level: 2 },
|
||||
{ id: 'update-flow-aliases', text: 'Update Flow Aliases', level: 2 },
|
||||
{ id: 'remove-flow-alias', text: 'Remove Flow Alias', level: 2 },
|
||||
{ id: 'regenerate-flow', text: 'Regenerate Flow', level: 2 },
|
||||
{ id: 'delete-flow', text: 'Delete Flow', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function FlowsAPIPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'API Reference', href: '/docs/api/' },
|
||||
{ label: 'Flows' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/generation/',
|
||||
title: 'Image Generation Guide',
|
||||
description: 'Learn about chaining generations with flows.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/api/generations/',
|
||||
title: 'Generations API',
|
||||
description: 'Create generations within flows.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Flows API"
|
||||
subtitle="Manage generation chains and flow-scoped aliases."
|
||||
/>
|
||||
|
||||
<section id="overview" className="mb-12">
|
||||
<SectionHeader level={2} id="overview">
|
||||
Overview
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-4">
|
||||
Flows group related generations together. They're created automatically when you chain generations using the same flowId.
|
||||
</p>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Flows also support flow-scoped aliases — named references to images that are unique within a flow but don't conflict with project-level aliases.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="list-flows" className="mb-12">
|
||||
<SectionHeader level={2} id="list-flows">
|
||||
List Flows
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve all flows for your project with computed counts.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/flows"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">limit</InlineCode>, 'number', 'Results per page (default: 20, max: 100)'],
|
||||
[<InlineCode key="p">offset</InlineCode>, 'number', 'Number of results to skip'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl "https://api.banatie.app/api/v1/flows?limit=10" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
||||
<ResponseBlock
|
||||
status="success"
|
||||
statusCode={200}
|
||||
statusLabel="200 OK"
|
||||
content={`{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "770e8400-e29b-41d4-a716-446655440002",
|
||||
"aliases": {"@hero": "img-uuid-1", "@background": "img-uuid-2"},
|
||||
"generationCount": 5,
|
||||
"imageCount": 5,
|
||||
"createdAt": "2025-01-15T10:00:00Z",
|
||||
"updatedAt": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 1,
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"hasMore": false
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="get-flow" className="mb-12">
|
||||
<SectionHeader level={2} id="get-flow">
|
||||
Get Flow
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve a single flow with detailed statistics.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/flows/:id"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="list-flow-generations" className="mb-12">
|
||||
<SectionHeader level={2} id="list-flow-generations">
|
||||
List Flow Generations
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve all generations in a specific flow.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/flows/:id/generations"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl "https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/generations?limit=20" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="list-flow-images" className="mb-12">
|
||||
<SectionHeader level={2} id="list-flow-images">
|
||||
List Flow Images
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve all images (generated and uploaded) in a flow.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/flows/:id/images"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl "https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/images" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="update-flow-aliases" className="mb-12">
|
||||
<SectionHeader level={2} id="update-flow-aliases">
|
||||
Update Flow Aliases
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Add or update flow-scoped aliases. Aliases are merged with existing ones.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="PUT"
|
||||
endpoint="/api/v1/flows/:id/aliases"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">aliases</InlineCode>, 'object', 'Key-value pairs of aliases to add/update'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X PUT https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/aliases \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"aliases": {
|
||||
"@hero": "image-id-123",
|
||||
"@background": "image-id-456"
|
||||
}
|
||||
}'`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="remove-flow-alias" className="mb-12">
|
||||
<SectionHeader level={2} id="remove-flow-alias">
|
||||
Remove Flow Alias
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Remove a specific alias from a flow.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="DELETE"
|
||||
endpoint="/api/v1/flows/:id/aliases/:alias"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X DELETE https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/aliases/@hero \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="regenerate-flow" className="mb-12">
|
||||
<SectionHeader level={2} id="regenerate-flow">
|
||||
Regenerate Flow
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Regenerate the most recent generation in the flow.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="POST"
|
||||
endpoint="/api/v1/flows/:id/regenerate"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002/regenerate \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="delete-flow" className="mb-12">
|
||||
<SectionHeader level={2} id="delete-flow">
|
||||
Delete Flow
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Delete a flow with cascade deletion. Images with project aliases are preserved.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="DELETE"
|
||||
endpoint="/api/v1/flows/:id"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X DELETE https://api.banatie.app/api/v1/flows/770e8400-e29b-41d4-a716-446655440002 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="warning">
|
||||
Deleting a flow removes all generations and images in it. Images with project aliases are preserved (unlinked from the flow).
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { Table } from '@/components/docs/shared/Table';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
||||
import {
|
||||
Hero,
|
||||
SectionHeader,
|
||||
InlineCode,
|
||||
EndpointCard,
|
||||
ResponseBlock,
|
||||
} from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['api-generations'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'API Reference', path: '/docs/api/' },
|
||||
{ name: 'Generations', path: '/docs/api/generations/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'create-generation', text: 'Create Generation', level: 2 },
|
||||
{ id: 'list-generations', text: 'List Generations', level: 2 },
|
||||
{ id: 'get-generation', text: 'Get Generation', level: 2 },
|
||||
{ id: 'update-generation', text: 'Update Generation', level: 2 },
|
||||
{ id: 'regenerate', text: 'Regenerate', level: 2 },
|
||||
{ id: 'delete-generation', text: 'Delete Generation', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function GenerationsAPIPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'API Reference', href: '/docs/api/' },
|
||||
{ label: 'Generations' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/generation/',
|
||||
title: 'Image Generation Guide',
|
||||
description: 'Concepts and examples for image generation.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/api/images/',
|
||||
title: 'Images API',
|
||||
description: 'Upload and manage images.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Generations API"
|
||||
subtitle="Create and manage AI image generations."
|
||||
/>
|
||||
|
||||
<section id="create-generation" className="mb-12">
|
||||
<SectionHeader level={2} id="create-generation">
|
||||
Create Generation
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Generate a new image from a text prompt.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="POST"
|
||||
endpoint="/api/v1/generations"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Required', 'Description']}
|
||||
rows={[
|
||||
[
|
||||
<InlineCode key="p">prompt</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">string</span>,
|
||||
<span key="r" className="text-green-400">Yes</span>,
|
||||
'Text description of the image to generate',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">aspectRatio</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">string</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'1:1, 16:9, 9:16, 3:2, 21:9 (default: 1:1)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">referenceImages</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">string[]</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Array of image IDs or @aliases to use as references',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">flowId</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">string</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Associate with existing flow',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">alias</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">string</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Project-scoped alias (@custom-name)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">autoEnhance</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">boolean</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Enable prompt enhancement (default: true)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">meta</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">object</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Custom metadata',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"prompt": "a serene mountain landscape at sunset",
|
||||
"aspectRatio": "16:9"
|
||||
}'`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
||||
<ResponseBlock
|
||||
status="success"
|
||||
statusCode={201}
|
||||
statusLabel="201 Created"
|
||||
content={`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": "success",
|
||||
"prompt": "a serene mountain landscape at sunset",
|
||||
"aspectRatio": "16:9",
|
||||
"outputImage": {
|
||||
"id": "8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
||||
"width": 1792,
|
||||
"height": 1008
|
||||
},
|
||||
"flowId": "770e8400-e29b-41d4-a716-446655440002",
|
||||
"createdAt": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="list-generations" className="mb-12">
|
||||
<SectionHeader level={2} id="list-generations">
|
||||
List Generations
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve all generations for your project with optional filtering.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/generations"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">flowId</InlineCode>, 'string', 'Filter by flow ID'],
|
||||
[<InlineCode key="p">status</InlineCode>, 'string', 'Filter by status: pending, processing, success, failed'],
|
||||
[<InlineCode key="p">limit</InlineCode>, 'number', 'Results per page (default: 20, max: 100)'],
|
||||
[<InlineCode key="p">offset</InlineCode>, 'number', 'Number of results to skip'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl "https://api.banatie.app/api/v1/generations?limit=10&status=success" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="get-generation" className="mb-12">
|
||||
<SectionHeader level={2} id="get-generation">
|
||||
Get Generation
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve a single generation by ID.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/generations/:id"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl https://api.banatie.app/api/v1/generations/550e8400-e29b-41d4-a716-446655440000 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="update-generation" className="mb-12">
|
||||
<SectionHeader level={2} id="update-generation">
|
||||
Update Generation
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Update generation parameters. Changing prompt or aspectRatio triggers automatic regeneration.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="PUT"
|
||||
endpoint="/api/v1/generations/:id"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">prompt</InlineCode>, 'string', 'New prompt (triggers regeneration)'],
|
||||
[<InlineCode key="p">aspectRatio</InlineCode>, 'string', 'New aspect ratio (triggers regeneration)'],
|
||||
[<InlineCode key="p">flowId</InlineCode>, 'string | null', 'Change flow association'],
|
||||
[<InlineCode key="p">meta</InlineCode>, 'object', 'Update custom metadata'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="info">
|
||||
Changing <InlineCode>prompt</InlineCode> or <InlineCode>aspectRatio</InlineCode> triggers a new generation. The image ID and URL remain the same — only the content changes.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="regenerate" className="mb-12">
|
||||
<SectionHeader level={2} id="regenerate">
|
||||
Regenerate
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Create a new image using the exact same parameters. Useful for getting a different result or recovering from failures.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="POST"
|
||||
endpoint="/api/v1/generations/:id/regenerate"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/generations/550e8400-e29b-41d4-a716-446655440000/regenerate \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="delete-generation" className="mb-12">
|
||||
<SectionHeader level={2} id="delete-generation">
|
||||
Delete Generation
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Delete a generation and its output image. Images with project aliases are preserved.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="DELETE"
|
||||
endpoint="/api/v1/generations/:id"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X DELETE https://api.banatie.app/api/v1/generations/550e8400-e29b-41d4-a716-446655440000 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
||||
<ResponseBlock
|
||||
status="success"
|
||||
statusCode={200}
|
||||
statusLabel="200 OK"
|
||||
content={`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,380 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { Table } from '@/components/docs/shared/Table';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
||||
import {
|
||||
Hero,
|
||||
SectionHeader,
|
||||
InlineCode,
|
||||
EndpointCard,
|
||||
ResponseBlock,
|
||||
} from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['api-images'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'API Reference', path: '/docs/api/' },
|
||||
{ name: 'Images', path: '/docs/api/images/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'upload-image', text: 'Upload Image', level: 2 },
|
||||
{ id: 'list-images', text: 'List Images', level: 2 },
|
||||
{ id: 'get-image', text: 'Get Image', level: 2 },
|
||||
{ id: 'update-image', text: 'Update Image', level: 2 },
|
||||
{ id: 'assign-alias', text: 'Assign Alias', level: 2 },
|
||||
{ id: 'delete-image', text: 'Delete Image', level: 2 },
|
||||
{ id: 'cdn-endpoints', text: 'CDN Endpoints', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function ImagesAPIPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'API Reference', href: '/docs/api/' },
|
||||
{ label: 'Images' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/images/',
|
||||
title: 'Working with Images Guide',
|
||||
description: 'Concepts and examples for image management.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/api/generations/',
|
||||
title: 'Generations API',
|
||||
description: 'Create AI-generated images.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Images API"
|
||||
subtitle="Upload and manage images for your project."
|
||||
/>
|
||||
|
||||
<section id="upload-image" className="mb-12">
|
||||
<SectionHeader level={2} id="upload-image">
|
||||
Upload Image
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Upload an image file to your project storage.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="POST"
|
||||
endpoint="/api/v1/images/upload"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Form Data</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Required', 'Description']}
|
||||
rows={[
|
||||
[
|
||||
<InlineCode key="p">file</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">file</span>,
|
||||
<span key="r" className="text-green-400">Yes</span>,
|
||||
'Image file (max 5MB, JPEG/PNG/WebP)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">alias</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">string</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Project-scoped alias (@custom-name)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">flowId</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">string</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Associate with flow',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">meta</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">string</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Custom metadata (JSON string)',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/images/upload \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-F "file=@your-image.png" \\
|
||||
-F "alias=@brand-logo"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
||||
<ResponseBlock
|
||||
status="success"
|
||||
statusCode={201}
|
||||
statusLabel="201 Created"
|
||||
content={`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/550e8400-e29b-41d4-a716-446655440000",
|
||||
"alias": "@brand-logo",
|
||||
"source": "uploaded",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"mimeType": "image/png",
|
||||
"fileSize": 24576,
|
||||
"createdAt": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="list-images" className="mb-12">
|
||||
<SectionHeader level={2} id="list-images">
|
||||
List Images
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve all images in your project with optional filtering.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/images"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">flowId</InlineCode>, 'string', 'Filter by flow ID'],
|
||||
[<InlineCode key="p">source</InlineCode>, 'string', 'Filter by source: generated, uploaded'],
|
||||
[<InlineCode key="p">alias</InlineCode>, 'string', 'Filter by exact alias match'],
|
||||
[<InlineCode key="p">limit</InlineCode>, 'number', 'Results per page (default: 20, max: 100)'],
|
||||
[<InlineCode key="p">offset</InlineCode>, 'number', 'Number of results to skip'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl "https://api.banatie.app/api/v1/images?source=uploaded&limit=20" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="get-image" className="mb-12">
|
||||
<SectionHeader level={2} id="get-image">
|
||||
Get Image
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve a single image by ID or alias.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/images/:id_or_alias"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Requests</h4>
|
||||
<CodeBlock
|
||||
code={`# By UUID
|
||||
curl https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"
|
||||
|
||||
# By alias
|
||||
curl https://api.banatie.app/api/v1/images/@brand-logo \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="update-image" className="mb-12">
|
||||
<SectionHeader level={2} id="update-image">
|
||||
Update Image
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Update image metadata (focal point and custom metadata).
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="PUT"
|
||||
endpoint="/api/v1/images/:id_or_alias"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">focalPoint</InlineCode>, 'object', 'Focal point for cropping {x: 0-1, y: 0-1}'],
|
||||
[<InlineCode key="p">meta</InlineCode>, 'object', 'Custom metadata'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"focalPoint": {"x": 0.5, "y": 0.3},
|
||||
"meta": {"category": "hero"}
|
||||
}'`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="assign-alias" className="mb-12">
|
||||
<SectionHeader level={2} id="assign-alias">
|
||||
Assign Alias
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Assign or remove a project-scoped alias from an image.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="PUT"
|
||||
endpoint="/api/v1/images/:id_or_alias/alias"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">alias</InlineCode>, 'string | null', 'Alias to assign (@name) or null to remove'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Requests</h4>
|
||||
<CodeBlock
|
||||
code={`# Assign alias
|
||||
curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"alias": "@hero-background"}'
|
||||
|
||||
# Remove alias
|
||||
curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"alias": null}'`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="delete-image" className="mb-12">
|
||||
<SectionHeader level={2} id="delete-image">
|
||||
Delete Image
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Permanently delete an image from storage.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="DELETE"
|
||||
endpoint="/api/v1/images/:id_or_alias"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X DELETE https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="warning">
|
||||
Deletion is permanent. The image file and all references are removed from storage.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="cdn-endpoints" className="mb-12">
|
||||
<SectionHeader level={2} id="cdn-endpoints">
|
||||
CDN Endpoints
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Access images directly via CDN (public, no authentication required):
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-white mb-3">By Image ID</h4>
|
||||
<CodeBlock
|
||||
code={`GET https://cdn.banatie.app/{org}/{project}/img/{imageId}`}
|
||||
language="text"
|
||||
filename="CDN by ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-white mb-3">By Alias</h4>
|
||||
<CodeBlock
|
||||
code={`GET https://cdn.banatie.app/{org}/{project}/img/@{alias}`}
|
||||
language="text"
|
||||
filename="CDN by Alias"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="info">
|
||||
CDN URLs are public and don't require authentication. They return image bytes directly with appropriate caching headers.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { Table } from '@/components/docs/shared/Table';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
||||
import {
|
||||
Hero,
|
||||
SectionHeader,
|
||||
InlineCode,
|
||||
EndpointCard,
|
||||
ResponseBlock,
|
||||
} from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['api-live-scopes'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'API Reference', path: '/docs/api/' },
|
||||
{ name: 'Live Scopes', path: '/docs/api/live-scopes/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'overview', text: 'Overview', level: 2 },
|
||||
{ id: 'create-scope', text: 'Create Scope', level: 2 },
|
||||
{ id: 'list-scopes', text: 'List Scopes', level: 2 },
|
||||
{ id: 'get-scope', text: 'Get Scope', level: 2 },
|
||||
{ id: 'update-scope', text: 'Update Scope', level: 2 },
|
||||
{ id: 'regenerate-scope', text: 'Regenerate Scope', level: 2 },
|
||||
{ id: 'delete-scope', text: 'Delete Scope', level: 2 },
|
||||
{ id: 'cdn-live-endpoint', text: 'CDN Live Endpoint', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function LiveScopesAPIPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'API Reference', href: '/docs/api/' },
|
||||
{ label: 'Live Scopes' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/live-urls/',
|
||||
title: 'Live URLs Guide',
|
||||
description: 'Learn about live URL generation.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/api/generations/',
|
||||
title: 'Generations API',
|
||||
description: 'Full control via the generations API.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Live Scopes API"
|
||||
subtitle="Manage scopes for live URL generation."
|
||||
/>
|
||||
|
||||
<section id="overview" className="mb-12">
|
||||
<SectionHeader level={2} id="overview">
|
||||
Overview
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-4">
|
||||
Live scopes organize live URL generations. Each scope has its own generation limit and can be configured independently.
|
||||
</p>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Scopes are auto-created on first use, but you can pre-configure them via this API to set custom limits.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="create-scope" className="mb-12">
|
||||
<SectionHeader level={2} id="create-scope">
|
||||
Create Scope
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Create a new live scope with custom settings.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="POST"
|
||||
endpoint="/api/v1/live/scopes"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Required', 'Description']}
|
||||
rows={[
|
||||
[
|
||||
<InlineCode key="p">slug</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">string</span>,
|
||||
<span key="r" className="text-green-400">Yes</span>,
|
||||
'Unique scope identifier (alphanumeric + hyphens + underscores)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">allowNewGenerations</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">boolean</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Allow new generations (default: true)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">newGenerationsLimit</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">number</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Maximum generations allowed (default: 30)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">meta</InlineCode>,
|
||||
<span key="t" className="text-cyan-400">object</span>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Custom metadata',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/live/scopes \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"slug": "hero-section",
|
||||
"allowNewGenerations": true,
|
||||
"newGenerationsLimit": 50,
|
||||
"meta": {"description": "Hero section images"}
|
||||
}'`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
||||
<ResponseBlock
|
||||
status="success"
|
||||
statusCode={201}
|
||||
statusLabel="201 Created"
|
||||
content={`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "880e8400-e29b-41d4-a716-446655440003",
|
||||
"slug": "hero-section",
|
||||
"allowNewGenerations": true,
|
||||
"newGenerationsLimit": 50,
|
||||
"currentGenerations": 0,
|
||||
"lastGeneratedAt": null,
|
||||
"meta": {"description": "Hero section images"},
|
||||
"createdAt": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="list-scopes" className="mb-12">
|
||||
<SectionHeader level={2} id="list-scopes">
|
||||
List Scopes
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve all live scopes for your project.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/live/scopes"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">slug</InlineCode>, 'string', 'Filter by exact slug match'],
|
||||
[<InlineCode key="p">limit</InlineCode>, 'number', 'Results per page (default: 20, max: 100)'],
|
||||
[<InlineCode key="p">offset</InlineCode>, 'number', 'Number of results to skip'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl "https://api.banatie.app/api/v1/live/scopes?limit=20" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="get-scope" className="mb-12">
|
||||
<SectionHeader level={2} id="get-scope">
|
||||
Get Scope
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Retrieve a single scope with statistics.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="GET"
|
||||
endpoint="/api/v1/live/scopes/:slug"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl https://api.banatie.app/api/v1/live/scopes/hero-section \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="update-scope" className="mb-12">
|
||||
<SectionHeader level={2} id="update-scope">
|
||||
Update Scope
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Update scope settings. Changes take effect immediately.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="PUT"
|
||||
endpoint="/api/v1/live/scopes/:slug"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">allowNewGenerations</InlineCode>, 'boolean', 'Allow/disallow new generations'],
|
||||
[<InlineCode key="p">newGenerationsLimit</InlineCode>, 'number', 'Update generation limit'],
|
||||
[<InlineCode key="p">meta</InlineCode>, 'object', 'Update custom metadata'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X PUT https://api.banatie.app/api/v1/live/scopes/hero-section \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"allowNewGenerations": false,
|
||||
"newGenerationsLimit": 100
|
||||
}'`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="regenerate-scope" className="mb-12">
|
||||
<SectionHeader level={2} id="regenerate-scope">
|
||||
Regenerate Scope
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Regenerate images in a scope. Can regenerate a specific image or all images.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="POST"
|
||||
endpoint="/api/v1/live/scopes/:slug/regenerate"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Request Body</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Type', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">imageId</InlineCode>, 'string', 'Specific image to regenerate (omit for all)'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Requests</h4>
|
||||
<CodeBlock
|
||||
code={`# Regenerate specific image
|
||||
curl -X POST https://api.banatie.app/api/v1/live/scopes/hero-section/regenerate \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"imageId": "550e8400-e29b-41d4-a716-446655440000"}'
|
||||
|
||||
# Regenerate all images in scope
|
||||
curl -X POST https://api.banatie.app/api/v1/live/scopes/hero-section/regenerate \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{}'`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Response</h4>
|
||||
<ResponseBlock
|
||||
status="success"
|
||||
statusCode={200}
|
||||
statusLabel="200 OK"
|
||||
content={`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"regenerated": 3,
|
||||
"images": [...]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="delete-scope" className="mb-12">
|
||||
<SectionHeader level={2} id="delete-scope">
|
||||
Delete Scope
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Delete a scope and all its cached images.
|
||||
</p>
|
||||
|
||||
<EndpointCard
|
||||
method="DELETE"
|
||||
endpoint="/api/v1/live/scopes/:slug"
|
||||
baseUrl="https://api.banatie.app"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example Request</h4>
|
||||
<CodeBlock
|
||||
code={`curl -X DELETE https://api.banatie.app/api/v1/live/scopes/hero-section \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="warning">
|
||||
Deleting a scope permanently removes all cached images in it. This cannot be undone.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="cdn-live-endpoint" className="mb-12">
|
||||
<SectionHeader level={2} id="cdn-live-endpoint">
|
||||
CDN Live Endpoint
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Public endpoint for live URL generation (no authentication required):
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`GET https://cdn.banatie.app/{org}/{project}/live/{scope}?prompt=...`}
|
||||
language="text"
|
||||
filename="Live URL Format"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Query Parameters</h4>
|
||||
<Table
|
||||
headers={['Parameter', 'Required', 'Description']}
|
||||
rows={[
|
||||
[
|
||||
<InlineCode key="p">prompt</InlineCode>,
|
||||
<span key="r" className="text-green-400">Yes</span>,
|
||||
'Text description of the image to generate',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">aspectRatio</InlineCode>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Image ratio (default: 16:9)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">autoEnhance</InlineCode>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Enable prompt enhancement',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">template</InlineCode>,
|
||||
<span key="r" className="text-gray-500">No</span>,
|
||||
'Enhancement template to use',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Example</h4>
|
||||
<CodeBlock
|
||||
code={`https://cdn.banatie.app/my-org/my-project/live/hero-section?prompt=mountain+landscape&aspectRatio=16:9`}
|
||||
language="text"
|
||||
filename="Live URL Example"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Response Headers</h4>
|
||||
<Table
|
||||
headers={['Header', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="h">X-Cache-Status</InlineCode>, 'HIT (cached) or MISS (generated)'],
|
||||
[<InlineCode key="h">X-Scope</InlineCode>, 'Scope identifier'],
|
||||
[<InlineCode key="h">X-Image-Id</InlineCode>, 'Image UUID'],
|
||||
[<InlineCode key="h">X-RateLimit-Remaining</InlineCode>, 'Remaining IP rate limit (on MISS)'],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { Table } from '@/components/docs/shared/Table';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema, API_REFERENCE_SCHEMA } from '@/config/docs-schema';
|
||||
import {
|
||||
Hero,
|
||||
SectionHeader,
|
||||
InlineCode,
|
||||
LinkCard,
|
||||
LinkCardGrid,
|
||||
} from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['api-overview'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'API Reference', path: '/docs/api/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'base-url', text: 'Base URL', level: 2 },
|
||||
{ id: 'authentication', text: 'Authentication', level: 2 },
|
||||
{ id: 'response-format', text: 'Response Format', level: 2 },
|
||||
{ id: 'error-codes', text: 'Common Error Codes', level: 2 },
|
||||
{ id: 'rate-limits', text: 'Rate Limits', level: 2 },
|
||||
{ id: 'endpoints', text: 'Endpoints', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function APIOverviewPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<JsonLd data={API_REFERENCE_SCHEMA} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'API Reference' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/api/generations/',
|
||||
title: 'Generations API',
|
||||
description: 'Create and manage image generations.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/api/images/',
|
||||
title: 'Images API',
|
||||
description: 'Upload and organize images.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="API Reference"
|
||||
subtitle="Complete REST API reference for Banatie. All endpoints, parameters, and response formats."
|
||||
/>
|
||||
|
||||
<section id="base-url" className="mb-12">
|
||||
<SectionHeader level={2} id="base-url">
|
||||
Base URL
|
||||
</SectionHeader>
|
||||
|
||||
<CodeBlock
|
||||
code={`https://api.banatie.app`}
|
||||
language="text"
|
||||
filename="Base URL"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="authentication" className="mb-12">
|
||||
<SectionHeader level={2} id="authentication">
|
||||
Authentication
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
All endpoints require the <InlineCode>X-API-Key</InlineCode> header:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`X-API-Key: your_api_key_here`}
|
||||
language="text"
|
||||
filename="Header"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
See <a href="/docs/authentication/" className="text-purple-400 hover:underline">Authentication</a> for details on obtaining and using API keys.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="response-format" className="mb-12">
|
||||
<SectionHeader level={2} id="response-format">
|
||||
Response Format
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
All responses follow a consistent JSON structure:
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300 mb-3">Success Response:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"success": true,
|
||||
"data": { ... }
|
||||
}`}
|
||||
language="json"
|
||||
filename="Success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300 mb-3">Error Response:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"success": false,
|
||||
"error": {
|
||||
"message": "Error description",
|
||||
"code": "ERROR_CODE"
|
||||
}
|
||||
}`}
|
||||
language="json"
|
||||
filename="Error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300 mb-3">Paginated Response:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"success": true,
|
||||
"data": [ ... ],
|
||||
"pagination": {
|
||||
"total": 100,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"hasMore": true
|
||||
}
|
||||
}`}
|
||||
language="json"
|
||||
filename="Paginated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="error-codes" className="mb-12">
|
||||
<SectionHeader level={2} id="error-codes">
|
||||
Common Error Codes
|
||||
</SectionHeader>
|
||||
|
||||
<Table
|
||||
headers={['Status', 'Code', 'Description']}
|
||||
rows={[
|
||||
[
|
||||
<InlineCode key="s" color="error">400</InlineCode>,
|
||||
'VALIDATION_ERROR',
|
||||
'Missing or invalid parameters',
|
||||
],
|
||||
[
|
||||
<InlineCode key="s" color="error">401</InlineCode>,
|
||||
'UNAUTHORIZED',
|
||||
'Missing or invalid API key',
|
||||
],
|
||||
[
|
||||
<InlineCode key="s" color="error">404</InlineCode>,
|
||||
'*_NOT_FOUND',
|
||||
'Requested resource not found',
|
||||
],
|
||||
[
|
||||
<InlineCode key="s" color="error">409</InlineCode>,
|
||||
'ALIAS_CONFLICT',
|
||||
'Alias already exists',
|
||||
],
|
||||
[
|
||||
<InlineCode key="s" color="error">429</InlineCode>,
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
'Too many requests',
|
||||
],
|
||||
[
|
||||
<InlineCode key="s" color="error">500</InlineCode>,
|
||||
'INTERNAL_ERROR',
|
||||
'Server error',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="rate-limits" className="mb-12">
|
||||
<SectionHeader level={2} id="rate-limits">
|
||||
Rate Limits
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
API requests are rate limited to 100 requests per hour per API key.
|
||||
</p>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Rate limit headers are included in every response:
|
||||
</p>
|
||||
|
||||
<Table
|
||||
headers={['Header', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="h">X-RateLimit-Limit</InlineCode>, 'Maximum requests per hour'],
|
||||
[<InlineCode key="h">X-RateLimit-Remaining</InlineCode>, 'Requests remaining in current window'],
|
||||
[<InlineCode key="h">X-RateLimit-Reset</InlineCode>, 'Unix timestamp when limit resets'],
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="endpoints" className="mb-12">
|
||||
<SectionHeader level={2} id="endpoints">
|
||||
Endpoints
|
||||
</SectionHeader>
|
||||
|
||||
<LinkCardGrid columns={2}>
|
||||
<LinkCard
|
||||
href="/docs/api/generations/"
|
||||
title="Generations"
|
||||
description="Create and manage AI image generations"
|
||||
accent="primary"
|
||||
/>
|
||||
<LinkCard
|
||||
href="/docs/api/images/"
|
||||
title="Images"
|
||||
description="Upload and organize images"
|
||||
accent="secondary"
|
||||
/>
|
||||
<LinkCard
|
||||
href="/docs/api/flows/"
|
||||
title="Flows"
|
||||
description="Manage generation chains"
|
||||
accent="primary"
|
||||
/>
|
||||
<LinkCard
|
||||
href="/docs/api/live-scopes/"
|
||||
title="Live Scopes"
|
||||
description="Control live URL generation"
|
||||
accent="secondary"
|
||||
/>
|
||||
</LinkCardGrid>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { Table } from '@/components/docs/shared/Table';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
||||
import {
|
||||
Hero,
|
||||
SectionHeader,
|
||||
InlineCode,
|
||||
} from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['authentication'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'Authentication', path: '/docs/authentication/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'early-access', text: 'Early Access', level: 2 },
|
||||
{ id: 'using-your-api-key', text: 'Using Your API Key', level: 2 },
|
||||
{ id: 'key-types', text: 'Key Types', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function AuthenticationPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'Authentication' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/generation/',
|
||||
title: 'Start Generating',
|
||||
description: 'Create your first AI-generated image.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/api/',
|
||||
title: 'API Reference',
|
||||
description: 'Full endpoint documentation.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Authentication"
|
||||
subtitle="How to authenticate with Banatie API using API keys."
|
||||
/>
|
||||
|
||||
<section id="early-access" className="mb-12">
|
||||
<SectionHeader level={2} id="early-access">
|
||||
Early Access
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
We're currently in early access phase. API keys are issued personally via email.
|
||||
</p>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
<strong className="text-white">To request access:</strong> Sign up at <a href="https://banatie.app" className="text-purple-400 hover:underline">banatie.app</a>. We'll send your API key within 24 hours.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="using-your-api-key" className="mb-12">
|
||||
<SectionHeader level={2} id="using-your-api-key">
|
||||
Using Your API Key
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
All API requests require the <InlineCode>X-API-Key</InlineCode> header:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
||||
-H "X-API-Key: your_key_here" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"prompt": "a sunset over the ocean"}'`}
|
||||
language="bash"
|
||||
filename="Authenticated Request"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="prominent" type="warning">
|
||||
<strong className="text-amber-300">Keep your API key secure.</strong> Don't commit it to version control or expose it in client-side code. Use environment variables in your applications.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="key-types" className="mb-12">
|
||||
<SectionHeader level={2} id="key-types">
|
||||
Key Types
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Banatie uses two types of API keys:
|
||||
</p>
|
||||
|
||||
<Table
|
||||
headers={['Type', 'Permissions', 'Expiration', 'Use Case']}
|
||||
rows={[
|
||||
[
|
||||
<InlineCode key="t">Project Key</InlineCode>,
|
||||
'Image generation, uploads, images',
|
||||
<span key="e" className="text-amber-400">90 days</span>,
|
||||
'Your application integration',
|
||||
],
|
||||
[
|
||||
<InlineCode key="t">Master Key</InlineCode>,
|
||||
'Full admin access, key management',
|
||||
<span key="e" className="text-green-400">Never expires</span>,
|
||||
'Server-side admin operations',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
You'll receive a Project Key for your application. Master Keys are for administrative operations — you probably don't need one.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="info">
|
||||
API key management dashboard coming soon. For now, contact us if you need to rotate your key.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { Table } from '@/components/docs/shared/Table';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
||||
import {
|
||||
Hero,
|
||||
SectionHeader,
|
||||
InlineCode,
|
||||
ResponseBlock,
|
||||
} from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['generation'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'Image Generation', path: '/docs/generation/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'basic-generation', text: 'Basic Generation', level: 2 },
|
||||
{ id: 'aspect-ratios', text: 'Aspect Ratios', level: 2 },
|
||||
{ id: 'prompt-templates', text: 'Prompt Templates', level: 2 },
|
||||
{ id: 'reference-images', text: 'Using Reference Images', level: 2 },
|
||||
{ id: 'continuing-generation', text: 'Continuing Generation', level: 2 },
|
||||
{ id: 'regeneration', text: 'Regeneration', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function GenerationPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'Image Generation' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/api/generations/',
|
||||
title: 'API Reference: Generations',
|
||||
description: 'Full endpoint documentation for generations.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/images/',
|
||||
title: 'Working with Images',
|
||||
description: 'Upload your own images and organize with aliases.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Image Generation"
|
||||
subtitle="Generate AI images from text prompts with support for references, templates, and chaining."
|
||||
/>
|
||||
|
||||
<section id="basic-generation" className="mb-12">
|
||||
<SectionHeader level={2} id="basic-generation">
|
||||
Basic Generation
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Generate an image by sending a text prompt to the generations endpoint:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"prompt": "a serene mountain landscape at sunset",
|
||||
"aspectRatio": "16:9"
|
||||
}'`}
|
||||
language="bash"
|
||||
filename="Create Generation"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">
|
||||
The response contains your generated image immediately:
|
||||
</p>
|
||||
|
||||
<ResponseBlock
|
||||
status="success"
|
||||
statusCode={201}
|
||||
statusLabel="201 Created"
|
||||
content={`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": "success",
|
||||
"prompt": "a serene mountain landscape at sunset",
|
||||
"aspectRatio": "16:9",
|
||||
"outputImage": {
|
||||
"id": "8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
||||
"width": 1792,
|
||||
"height": 1008
|
||||
},
|
||||
"flowId": "770e8400-e29b-41d4-a716-446655440002"
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
One request, one result. The <InlineCode>storageUrl</InlineCode> is your production-ready image, served via CDN.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="aspect-ratios" className="mb-12">
|
||||
<SectionHeader level={2} id="aspect-ratios">
|
||||
Aspect Ratios
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Choose the aspect ratio that fits your use case:
|
||||
</p>
|
||||
|
||||
<Table
|
||||
headers={['Ratio', 'Dimensions', 'Best For']}
|
||||
rows={[
|
||||
[
|
||||
<InlineCode key="ratio">1:1</InlineCode>,
|
||||
'1024 x 1024',
|
||||
'Social media posts, profile pictures, thumbnails',
|
||||
],
|
||||
[
|
||||
<InlineCode key="ratio">16:9</InlineCode>,
|
||||
'1792 x 1008',
|
||||
'Blog headers, presentations, video thumbnails',
|
||||
],
|
||||
[
|
||||
<InlineCode key="ratio">9:16</InlineCode>,
|
||||
'1008 x 1792',
|
||||
'Stories, mobile backgrounds, vertical banners',
|
||||
],
|
||||
[
|
||||
<InlineCode key="ratio">3:2</InlineCode>,
|
||||
'1536 x 1024',
|
||||
'Photography-style images, print layouts',
|
||||
],
|
||||
[
|
||||
<InlineCode key="ratio">21:9</InlineCode>,
|
||||
'2016 x 864',
|
||||
'Ultra-wide banners, cinematic headers',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
Default is <InlineCode>16:9</InlineCode> if not specified.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="prompt-templates" className="mb-12">
|
||||
<SectionHeader level={2} id="prompt-templates">
|
||||
Prompt Templates
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Templates improve your prompt for specific styles. Available templates:
|
||||
</p>
|
||||
|
||||
<Table
|
||||
headers={['Template', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="t">general</InlineCode>, 'Balanced style for most use cases'],
|
||||
[<InlineCode key="t">photorealistic</InlineCode>, 'Photo-like realism with natural lighting'],
|
||||
[<InlineCode key="t">illustration</InlineCode>, 'Artistic illustration style'],
|
||||
[<InlineCode key="t">minimalist</InlineCode>, 'Clean, simple compositions'],
|
||||
[<InlineCode key="t">sticker</InlineCode>, 'Sticker-style with clear edges'],
|
||||
[<InlineCode key="t">product</InlineCode>, 'E-commerce product photography'],
|
||||
[<InlineCode key="t">comic</InlineCode>, 'Comic book visual style'],
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="info">
|
||||
Template selection coming soon. Currently uses general style for all generations.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="reference-images" className="mb-12">
|
||||
<SectionHeader level={2} id="reference-images">
|
||||
Using Reference Images
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Add reference images for style guidance or context. Pass image IDs or aliases in the <InlineCode>referenceImages</InlineCode> array:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"prompt": "product photo in this style",
|
||||
"referenceImages": ["@brand-style", "@product-template"],
|
||||
"aspectRatio": "1:1"
|
||||
}'`}
|
||||
language="bash"
|
||||
filename="With References"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="info">
|
||||
<strong>Pro Tip:</strong> Use aliases like <InlineCode>@logo</InlineCode> instead of UUIDs. See <a href="/docs/images/" className="text-purple-400 hover:underline">Working with Images</a> to learn about aliases.
|
||||
</TipBox>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
You can also mention aliases directly in your prompt text — they're auto-detected:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"prompt": "create a banner using @brand-colors and @logo style"
|
||||
}`}
|
||||
language="json"
|
||||
filename="Auto-detected aliases"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="continuing-generation" className="mb-12">
|
||||
<SectionHeader level={2} id="continuing-generation">
|
||||
Continuing Generation
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Chain multiple generations together by passing the same <InlineCode>flowId</InlineCode>:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"prompt": "same scene but at night",
|
||||
"flowId": "770e8400-e29b-41d4-a716-446655440002"
|
||||
}'`}
|
||||
language="bash"
|
||||
filename="Continue in Flow"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
Each response includes a <InlineCode>flowId</InlineCode> you can use to continue the sequence. Flows help organize related generations together.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="regeneration" className="mb-12">
|
||||
<SectionHeader level={2} id="regeneration">
|
||||
Regeneration
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Want a different result with the same parameters? Regenerate an existing generation:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/generations/550e8400-e29b-41d4-a716-446655440000/regenerate \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Regenerate"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
Same prompt, new image. The generation ID and URL stay the same — the image content is replaced.
|
||||
</p>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema } from '@/config/docs-schema';
|
||||
import { Hero } from '@/components/docs/blocks';
|
||||
import Link from 'next/link';
|
||||
|
||||
const PAGE = DOCS_PAGES['guides'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'Guides', path: '/docs/guides/' },
|
||||
]);
|
||||
|
||||
export default function GuidesPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'Guides' },
|
||||
]}
|
||||
tocItems={[]}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/live-urls/',
|
||||
title: 'Live URLs',
|
||||
description: 'Generate images directly from URL parameters.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/generation/',
|
||||
title: 'Image Generation',
|
||||
description: 'Full control via the API.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Guides"
|
||||
subtitle="Step-by-step tutorials for common use cases."
|
||||
/>
|
||||
|
||||
<section className="mb-12">
|
||||
<div className="grid gap-4">
|
||||
<Link
|
||||
href="/docs/guides/placeholder-images/"
|
||||
className="block p-6 bg-slate-800/50 border border-slate-700 rounded-lg hover:border-purple-500/50 transition-colors"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">AI Placeholder Images</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Generate contextual placeholder images for development.
|
||||
Replace gray boxes with AI visuals that match your design.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,721 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { Table } from '@/components/docs/shared/Table';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { LivePreview } from '@/components/docs/shared/LivePreview';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
||||
import { Hero, SectionHeader, InlineCode } from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['guide-placeholder-images'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'Guides', path: '/docs/guides/' },
|
||||
{ name: 'Placeholder Images', path: '/docs/guides/placeholder-images/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'what-this-guide-covers', text: 'What This Guide Covers', level: 2 },
|
||||
{ id: 'how-to-create-placeholders', text: 'How to Create Placeholders', level: 2 },
|
||||
{ id: 'quick-start', text: 'Quick Start', level: 2 },
|
||||
{ id: 'organizing-placeholders', text: 'Organizing Placeholders', level: 2 },
|
||||
{ id: 'prompt-tips', text: 'Prompt Tips', level: 2 },
|
||||
{ id: 'light-mode-placeholders', text: 'Light Mode Placeholders', level: 2 },
|
||||
{ id: 'dark-mode-placeholders', text: 'Dark Mode Placeholders', level: 2 },
|
||||
{ id: 'placeholder-image-examples', text: 'Placeholder Image Examples', level: 2 },
|
||||
{ id: 'file-based-workflow', text: 'File-based Workflow', level: 2 },
|
||||
];
|
||||
|
||||
export default function PlaceholderImagesGuidePage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'Guides', href: '/docs/guides/' },
|
||||
{ label: 'Placeholder Images' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/live-urls/',
|
||||
title: 'Live URLs Reference',
|
||||
description: 'Full parameter documentation for Live URLs.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/api/generations/',
|
||||
title: 'Generations API',
|
||||
description: 'Generate images programmatically.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="AI Placeholder Images"
|
||||
subtitle="Generate contextual images for development. The new era of AI placeholders."
|
||||
/>
|
||||
|
||||
{/* What This Guide Covers */}
|
||||
<section id="what-this-guide-covers" className="mb-12 scroll-mt-24">
|
||||
<SectionHeader level={2} id="what-this-guide-covers">
|
||||
What This Guide Covers
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-4">
|
||||
Two ways to generate placeholder images with Banatie:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-300 space-y-2 mb-6">
|
||||
<li>
|
||||
<a href="#quick-start" className="text-white font-semibold hover:text-purple-400">
|
||||
Live URLs
|
||||
</a>{' '}
|
||||
— describe what you need right in <InlineCode><img></InlineCode> src URLs
|
||||
</li>
|
||||
<li>
|
||||
<a href="#file-based-workflow" className="text-white font-semibold hover:text-purple-400">
|
||||
API generation
|
||||
</a>{' '}
|
||||
— full control, permanent URLs, downloadable files
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
All examples on this page use real placeholder image URLs generated by Banatie.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* How to Create Placeholders */}
|
||||
<section id="how-to-create-placeholders" className="mb-12 scroll-mt-24">
|
||||
<SectionHeader level={2} id="how-to-create-placeholders">
|
||||
How to Create Placeholders
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-2">Templates</h4>
|
||||
<p className="text-gray-300 mb-2">
|
||||
Choose a style, get quality results. Banatie enhances your simple prompt based on
|
||||
the selected template:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-300 space-y-1">
|
||||
<li>
|
||||
<InlineCode>photorealistic</InlineCode> — photo-quality images
|
||||
</li>
|
||||
<li>
|
||||
<InlineCode>digital-art</InlineCode> — stylized illustrations
|
||||
</li>
|
||||
<li>
|
||||
<InlineCode>3d-render</InlineCode> — 3D graphics
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-400 text-sm mt-3">
|
||||
<a href="/docs/generation/#prompt-templates" className="text-purple-400 hover:underline">
|
||||
View all templates →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-2">Simple Prompts</h4>
|
||||
<p className="text-gray-300 mb-2">
|
||||
Write minimal descriptions. Templates handle the rest:
|
||||
</p>
|
||||
<ul className="list-none text-gray-300 space-y-1">
|
||||
<li>
|
||||
<span className="text-gray-500">→</span> "office" becomes a detailed
|
||||
modern office with proper lighting
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-gray-500">→</span> "headshot" becomes a
|
||||
professional portrait with studio background
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-400 text-sm mt-3">
|
||||
No need for complex prompts — this is for placeholders, not art.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Start */}
|
||||
<section id="quick-start" className="mb-12 scroll-mt-24">
|
||||
<SectionHeader level={2} id="quick-start">
|
||||
Quick Start
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Live URLs let you generate images by describing what you need right in the URL. No API
|
||||
calls, no file management — just an HTML placeholder image tag with your prompt. Each
|
||||
unique prompt is cached, so subsequent loads are instant via CDN.
|
||||
</p>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mb-4">Basic URL format:</p>
|
||||
<CodeBlock
|
||||
code="https://cdn.banatie.app/{org}/{project}/live/{scope}?prompt={description}&aspectRatio={ratio}"
|
||||
language="text"
|
||||
filename="URL Format"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Example:</p>
|
||||
<CodeBlock
|
||||
code={`<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/test?prompt=mountain+landscape"
|
||||
alt="Mountain landscape"
|
||||
/>`}
|
||||
language="html"
|
||||
filename="HTML Placeholder Image"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-4 mb-2">Result:</p>
|
||||
<LivePreview showLabel={false}>
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/test?prompt=mountain+landscape&aspectRatio=16:9"
|
||||
alt="Mountain landscape"
|
||||
className="w-full max-w-md rounded-lg"
|
||||
/>
|
||||
</LivePreview>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Parameters:</p>
|
||||
<Table
|
||||
headers={['Parameter', 'Required', 'Description']}
|
||||
rows={[
|
||||
[<InlineCode key="p">prompt</InlineCode>, 'Yes', 'Image description (URL-encoded)'],
|
||||
[
|
||||
<InlineCode key="a">aspectRatio</InlineCode>,
|
||||
'No',
|
||||
'Ratio like 1:1, 16:9, 4:3 (default: 16:9)',
|
||||
],
|
||||
[<InlineCode key="t">template</InlineCode>, 'No', 'Style template name'],
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
For full parameter reference, see{' '}
|
||||
<a href="/docs/live-urls/" className="text-purple-400 hover:underline">
|
||||
Live URLs documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Organizing Placeholders */}
|
||||
<section id="organizing-placeholders" className="mb-12 scroll-mt-24">
|
||||
<SectionHeader level={2} id="organizing-placeholders">
|
||||
Organizing Placeholders
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-4">
|
||||
Organize images by sections of your site using scopes:
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`/live/avatars?prompt=... → user photos
|
||||
/live/hero?prompt=... → hero backgrounds
|
||||
/live/products?prompt=... → product catalog`}
|
||||
language="text"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
Learn more about scopes in{' '}
|
||||
<a href="/docs/live-urls/#scopes" className="text-purple-400 hover:underline">
|
||||
Live URLs documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Prompt Tips */}
|
||||
<section id="prompt-tips" className="mb-12 scroll-mt-24">
|
||||
<SectionHeader level={2} id="prompt-tips">
|
||||
Prompt Tips
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-2">Write less, not more</h4>
|
||||
<p className="text-gray-300 mb-2">
|
||||
For placeholders, simple prompts are often enough. You can always add more details
|
||||
later if needed. Templates handle the rest:
|
||||
</p>
|
||||
<ul className="list-none text-gray-300 space-y-1">
|
||||
<li>
|
||||
<span className="text-gray-500">→</span> Want an office? Write{' '}
|
||||
<InlineCode>office</InlineCode>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-gray-500">→</span> Need a dark version? Add{' '}
|
||||
<InlineCode>office dark background</InlineCode>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-gray-500">→</span> Templates handle lighting, composition,
|
||||
style
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-2">Colors and themes</h4>
|
||||
<p className="text-gray-300 mb-2">Control the mood with color hints:</p>
|
||||
<CodeBlock
|
||||
code={`"dark background" → dark theme
|
||||
"blue and orange accents" → specific palette
|
||||
"warm lighting" → cozy feel`}
|
||||
language="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="protip">
|
||||
Templates automatically enhance your prompts. A simple description becomes a detailed
|
||||
generation instruction.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Light Mode Placeholders */}
|
||||
<section id="light-mode-placeholders" className="mb-12 scroll-mt-24">
|
||||
<SectionHeader level={2} id="light-mode-placeholders">
|
||||
Light Mode Placeholders
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-4">
|
||||
Generated images work well with light interfaces by default. If you need more control,
|
||||
specify background colors or accents to match your design system.
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`"product on white background"
|
||||
"office with soft natural light"
|
||||
"portrait, bright studio, pastel tones"
|
||||
"dashboard mockup, light gray background, blue accent"`}
|
||||
language="text"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<LivePreview label="Light Background Example">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=minimalist+desk+setup+white+background+clean+aesthetic&aspectRatio=16:9"
|
||||
alt="Light theme placeholder"
|
||||
className="w-full rounded-lg mb-4"
|
||||
/>
|
||||
<p className="text-gray-900 font-semibold">Clean Workspace</p>
|
||||
<p className="text-gray-600 text-sm">White background, natural lighting</p>
|
||||
</div>
|
||||
</LivePreview>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Dark Mode Placeholders */}
|
||||
<section id="dark-mode-placeholders" className="mb-12 scroll-mt-24">
|
||||
<SectionHeader level={2} id="dark-mode-placeholders">
|
||||
Dark Mode Placeholders
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-4">
|
||||
For dark interfaces, add <InlineCode>dark background</InlineCode> or descriptive words
|
||||
like night, moody, or twilight. You can also specify accent colors.
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`"office interior, dark background"
|
||||
"product photo, dark surface, moody lighting"
|
||||
"night cityscape, neon accents"
|
||||
"abstract gradient, dark purple and blue"`}
|
||||
language="text"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<LivePreview label="Dark Background Example">
|
||||
<div className="bg-slate-900 rounded-xl p-6 max-w-md border border-slate-700">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/hero?prompt=abstract+gradient+dark+background+purple+blue+moody&aspectRatio=16:9"
|
||||
alt="Dark theme placeholder"
|
||||
className="w-full rounded-lg mb-4"
|
||||
/>
|
||||
<p className="text-white font-semibold">Dark Gradient</p>
|
||||
<p className="text-gray-400 text-sm">Dark background with purple accents</p>
|
||||
</div>
|
||||
</LivePreview>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Placeholder Image Examples */}
|
||||
<section id="placeholder-image-examples" className="mb-12 scroll-mt-24">
|
||||
<SectionHeader level={2} id="placeholder-image-examples">
|
||||
Placeholder Image Examples
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-8">
|
||||
Copy-paste examples for common placeholder image use cases.
|
||||
</p>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="mb-10">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Avatar</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
<InlineCode>1:1</InlineCode> · User profiles, team sections, testimonials
|
||||
</p>
|
||||
|
||||
<LivePreview>
|
||||
<div className="bg-slate-800/50 rounded-xl p-6 max-w-md">
|
||||
<div className="flex items-start gap-4">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/avatars?prompt=professional+studio+headshot+confident+woman+neutral+background&aspectRatio=1:1"
|
||||
alt="Sarah Chen"
|
||||
className="w-14 h-14 rounded-full object-cover flex-shrink-0"
|
||||
/>
|
||||
<div className="border-l-2 border-purple-500/50 pl-4">
|
||||
<p className="text-gray-300 italic">
|
||||
Banatie cut our design mockup time in half. No more hunting for stock photos.
|
||||
</p>
|
||||
<p className="text-white font-semibold mt-3">Sarah Chen</p>
|
||||
<p className="text-gray-500 text-sm">Product Designer at Acme</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LivePreview>
|
||||
|
||||
<CodeBlock
|
||||
code={`<img
|
||||
src="https://cdn.banatie.app/{org}/{project}/live/avatars?prompt=professional+headshot&aspectRatio=1:1"
|
||||
alt="User avatar"
|
||||
class="w-14 h-14 rounded-full object-cover"
|
||||
/>`}
|
||||
language="html"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-gray-400 text-sm mb-3">Team grid example:</p>
|
||||
<LivePreview showLabel={false}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/avatars?prompt=professional+headshot+young+man+friendly+smile&aspectRatio=1:1"
|
||||
alt="Alex Rivera"
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white font-medium">Alex Rivera</p>
|
||||
<p className="text-gray-400 text-sm">Engineering Lead</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/avatars?prompt=professional+headshot+woman+glasses+confident&aspectRatio=1:1"
|
||||
alt="Maria Santos"
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white font-medium">Maria Santos</p>
|
||||
<p className="text-gray-400 text-sm">Design Director</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/avatars?prompt=professional+headshot+man+beard+casual&aspectRatio=1:1"
|
||||
alt="James Wilson"
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white font-medium">James Wilson</p>
|
||||
<p className="text-gray-400 text-sm">Product Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LivePreview>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product */}
|
||||
<div className="mb-10">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Product</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
<InlineCode>1:1</InlineCode> or <InlineCode>4:5</InlineCode> · E-commerce, catalogs,
|
||||
listings
|
||||
</p>
|
||||
|
||||
<LivePreview>
|
||||
<div className="bg-white rounded-xl p-4 max-w-xs shadow-lg">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=minimalist+wireless+headphones+white+background+product+photo&aspectRatio=1:1"
|
||||
alt="Wireless Pro Headphones"
|
||||
className="w-full aspect-square object-cover rounded-lg mb-4"
|
||||
/>
|
||||
<p className="text-gray-900 font-semibold">Wireless Pro Headphones</p>
|
||||
<p className="text-gray-600 text-lg font-bold mt-1">$299</p>
|
||||
<button className="mt-3 w-full bg-gray-900 text-white py-2 px-4 rounded-lg text-sm font-medium">
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</LivePreview>
|
||||
|
||||
<CodeBlock
|
||||
code={`<img
|
||||
src="https://cdn.banatie.app/{org}/{project}/live/products?prompt=product+photo+white+background&aspectRatio=1:1"
|
||||
alt="Product"
|
||||
class="w-full aspect-square object-cover rounded-lg"
|
||||
/>`}
|
||||
language="html"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-gray-400 text-sm mb-3">Product grid example:</p>
|
||||
<LivePreview showLabel={false}>
|
||||
<div className="grid grid-cols-2 gap-4 max-w-lg">
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=modern+smart+watch+product+photo&aspectRatio=1:1"
|
||||
alt="Smart Watch X1"
|
||||
className="w-full aspect-square object-cover rounded-md mb-2"
|
||||
/>
|
||||
<p className="text-gray-900 font-medium text-sm">Smart Watch X1</p>
|
||||
<p className="text-gray-600 font-bold">$199</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=wireless+earbuds+case+product+photo&aspectRatio=1:1"
|
||||
alt="Wireless Earbuds"
|
||||
className="w-full aspect-square object-cover rounded-md mb-2"
|
||||
/>
|
||||
<p className="text-gray-900 font-medium text-sm">Wireless Earbuds</p>
|
||||
<p className="text-gray-600 font-bold">$249</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=portable+bluetooth+speaker+product+photo&aspectRatio=1:1"
|
||||
alt="Portable Speaker"
|
||||
className="w-full aspect-square object-cover rounded-md mb-2"
|
||||
/>
|
||||
<p className="text-gray-900 font-medium text-sm">Portable Speaker</p>
|
||||
<p className="text-gray-600 font-bold">$79</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/products?prompt=laptop+stand+aluminum+product&aspectRatio=1:1"
|
||||
alt="Laptop Stand"
|
||||
className="w-full aspect-square object-cover rounded-md mb-2"
|
||||
/>
|
||||
<p className="text-gray-900 font-medium text-sm">Laptop Stand</p>
|
||||
<p className="text-gray-600 font-bold">$129</p>
|
||||
</div>
|
||||
</div>
|
||||
</LivePreview>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="mb-10">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Hero</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
<InlineCode>16:9</InlineCode> · Landing pages, section backgrounds
|
||||
</p>
|
||||
|
||||
<LivePreview className="p-0">
|
||||
<div className="relative w-full h-72 rounded-xl overflow-hidden">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/hero?prompt=aerial+view+modern+city+skyline+sunset+dramatic+lighting&aspectRatio=16:9"
|
||||
alt="Hero background"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center text-center px-4">
|
||||
<p className="text-3xl font-bold text-white mb-2">Build the Future</p>
|
||||
<p className="text-gray-200">Start your next project with AI-powered tools</p>
|
||||
</div>
|
||||
</div>
|
||||
</LivePreview>
|
||||
|
||||
<CodeBlock
|
||||
code={`<div class="relative w-full h-96 overflow-hidden">
|
||||
<img
|
||||
src="https://cdn.banatie.app/{org}/{project}/live/hero?prompt=abstract+tech+background&aspectRatio=16:9"
|
||||
alt="Hero background"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<h1 class="text-4xl font-bold text-white">Your Headline</h1>
|
||||
</div>
|
||||
</div>`}
|
||||
language="html"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OG Image */}
|
||||
<div className="mb-10">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">OG Image</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
<InlineCode>1200:630</InlineCode> · Social sharing, Twitter/LinkedIn cards
|
||||
</p>
|
||||
|
||||
<LivePreview>
|
||||
<div className="bg-slate-800 rounded-lg overflow-hidden max-w-md shadow-xl">
|
||||
<div className="bg-slate-700 px-3 py-2 flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-3 h-3 rounded-full bg-red-500"></span>
|
||||
<span className="w-3 h-3 rounded-full bg-yellow-500"></span>
|
||||
<span className="w-3 h-3 rounded-full bg-green-500"></span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-xs ml-2">twitter.com</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/og?prompt=modern+tech+abstract+waves+purple+blue&aspectRatio=16:9"
|
||||
alt="OG Image Preview"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
<p className="text-gray-400 text-xs mt-2">banatie.app</p>
|
||||
<p className="text-white font-medium mt-1">
|
||||
AI Placeholder Images Guide | Banatie
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Generate AI placeholder images for development...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</LivePreview>
|
||||
|
||||
<CodeBlock
|
||||
code={`<meta property="og:image" content="https://cdn.banatie.app/{org}/{project}/live/og?prompt=your+description&aspectRatio=1200:630" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />`}
|
||||
language="html"
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<TipBox variant="compact" type="info">
|
||||
OG images are cached by social platforms. Change the prompt to regenerate.
|
||||
</TipBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mb-10">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Features</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
<InlineCode>1:1</InlineCode> · Feature grids, benefit sections, icons
|
||||
</p>
|
||||
|
||||
<LivePreview>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="group bg-slate-800/40 border border-slate-700/30 rounded-2xl overflow-hidden text-center transition-all duration-300 hover:border-slate-600/50 hover:shadow-lg hover:shadow-purple-500/10 hover:-translate-y-1">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/features?prompt=lightning+bolt+icon+single+line+art+illustration+minimal+background+hex+1e293b&aspectRatio=1:1"
|
||||
alt="Lightning Fast"
|
||||
className="w-full aspect-square object-cover"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<p className="font-semibold text-white">Lightning Fast</p>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Deploy in seconds with our global CDN
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group bg-slate-800/40 border border-slate-700/30 rounded-2xl overflow-hidden text-center transition-all duration-300 hover:border-slate-600/50 hover:shadow-lg hover:shadow-emerald-500/10 hover:-translate-y-1">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/features?prompt=shield+icon+single+line+art+illustration+minimal+background+hex+1e293b&aspectRatio=1:1"
|
||||
alt="Secure by Default"
|
||||
className="w-full aspect-square object-cover"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<p className="font-semibold text-white">Secure by Default</p>
|
||||
<p className="mt-1 text-sm text-gray-400">Enterprise-grade security built in</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group bg-slate-800/40 border border-slate-700/30 rounded-2xl overflow-hidden text-center transition-all duration-300 hover:border-slate-600/50 hover:shadow-lg hover:shadow-violet-500/10 hover:-translate-y-1">
|
||||
<img
|
||||
src="https://cdn.banatie.app/sys/demo/live/features?prompt=puzzle+piece+icon+single+line+art+illustration+minimal+background+hex+1e293b&aspectRatio=1:1"
|
||||
alt="Easy Integration"
|
||||
className="w-full aspect-square object-cover"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<p className="font-semibold text-white">Easy Integration</p>
|
||||
<p className="mt-1 text-sm text-gray-400">Works with your existing stack</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LivePreview>
|
||||
|
||||
<CodeBlock
|
||||
code={`<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="bg-slate-800/40 border border-slate-700/30 rounded-2xl overflow-hidden text-center
|
||||
transition-all duration-300 hover:border-slate-600/50 hover:shadow-lg hover:-translate-y-1">
|
||||
<img
|
||||
src="https://cdn.banatie.app/{org}/{project}/live/features?prompt=lightning+bolt+icon+single+line+art+illustration+minimal+background+hex+1e293b&aspectRatio=1:1"
|
||||
alt="Lightning Fast"
|
||||
class="w-full aspect-square object-cover"
|
||||
/>
|
||||
<div class="p-4">
|
||||
<p class="font-semibold text-white">Lightning Fast</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Deploy in seconds with our global CDN</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Repeat for other features -->
|
||||
</div>`}
|
||||
language="html"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* File-based Workflow */}
|
||||
<section id="file-based-workflow" className="mb-12 scroll-mt-24">
|
||||
<SectionHeader level={2} id="file-based-workflow">
|
||||
File-based Workflow
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Need files in your repo? Here's how to download generated images.
|
||||
</p>
|
||||
|
||||
<h4 className="text-white font-semibold mb-3">When to Use Files</h4>
|
||||
<ul className="list-disc list-inside text-gray-300 space-y-2 mb-6">
|
||||
<li>Next.js/React projects with local image imports</li>
|
||||
<li>Version-controlled placeholder assets</li>
|
||||
<li>Offline or CI/CD environments</li>
|
||||
</ul>
|
||||
|
||||
<h4 className="text-white font-semibold mb-3">Generate via API</h4>
|
||||
<p className="text-gray-300 leading-relaxed mb-4">Request:</p>
|
||||
<CodeBlock
|
||||
code={`POST https://api.banatie.app/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: your_api_key
|
||||
|
||||
{
|
||||
"prompt": "modern office interior"
|
||||
}`}
|
||||
language="http"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Response:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"image": {
|
||||
"id": "img_abc123",
|
||||
"cdnUrl": "https://cdn.banatie.app/org/project/images/2025-01/abc123.png"
|
||||
}
|
||||
}`}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
Open <InlineCode>cdnUrl</InlineCode> in your browser, save the image, and add it to your
|
||||
project's assets folder.
|
||||
</p>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-4">
|
||||
For full API reference, see{' '}
|
||||
<a href="/docs/api/generations/" className="text-purple-400 hover:underline">
|
||||
Generations API
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
||||
import {
|
||||
Hero,
|
||||
SectionHeader,
|
||||
InlineCode,
|
||||
ResponseBlock,
|
||||
} from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['images'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'Working with Images', path: '/docs/images/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'image-urls', text: 'Image URLs', level: 2 },
|
||||
{ id: 'uploading-images', text: 'Uploading Images', level: 2 },
|
||||
{ id: 'listing-images', text: 'Listing & Getting Images', level: 2 },
|
||||
{ id: 'aliases', text: 'Aliases', level: 2 },
|
||||
{ id: 'deleting-images', text: 'Deleting Images', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function ImagesPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'Working with Images' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/api/images/',
|
||||
title: 'API Reference: Images',
|
||||
description: 'Full endpoint documentation for images.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/generation/',
|
||||
title: 'Image Generation',
|
||||
description: 'Use your images as references in generations.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Working with Images"
|
||||
subtitle="Upload, manage, and organize your images. CDN delivery, aliases, and image management."
|
||||
/>
|
||||
|
||||
<section id="image-urls" className="mb-12">
|
||||
<SectionHeader level={2} id="image-urls">
|
||||
Image URLs
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
All images are served via CDN with this URL structure:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`https://cdn.banatie.app/{org}/{project}/img/{imageId}`}
|
||||
language="text"
|
||||
filename="CDN URL Format"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
URLs are permanent, fast, and cached globally. Use them directly in your applications.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="uploading-images" className="mb-12">
|
||||
<SectionHeader level={2} id="uploading-images">
|
||||
Uploading Images
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Upload your own images for use as brand assets, references, or logos:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/images/upload \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-F "file=@your-image.png" \\
|
||||
-F "alias=@brand-logo"`}
|
||||
language="bash"
|
||||
filename="Upload Image"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">
|
||||
Response includes the CDN URL and image details:
|
||||
</p>
|
||||
|
||||
<ResponseBlock
|
||||
status="success"
|
||||
statusCode={201}
|
||||
statusLabel="201 Created"
|
||||
content={`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/550e8400-e29b-41d4-a716-446655440000",
|
||||
"alias": "@brand-logo",
|
||||
"source": "uploaded",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"mimeType": "image/png",
|
||||
"fileSize": 24576
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="listing-images" className="mb-12">
|
||||
<SectionHeader level={2} id="listing-images">
|
||||
Listing & Getting Images
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
List all images in your project:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl https://api.banatie.app/api/v1/images \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="List Images"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-6">
|
||||
Get a specific image by ID or alias:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`# By UUID
|
||||
curl https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"
|
||||
|
||||
# By alias
|
||||
curl https://api.banatie.app/api/v1/images/@brand-logo \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Get Image"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="aliases" className="mb-12">
|
||||
<SectionHeader level={2} id="aliases">
|
||||
Aliases
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Assign memorable names to images. Aliases start with <InlineCode>@</InlineCode> and make it easy to reference images without remembering UUIDs.
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"alias": "@hero-background"}'`}
|
||||
language="bash"
|
||||
filename="Assign Alias"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-6">
|
||||
Access images via CDN using their alias:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`https://cdn.banatie.app/my-org/my-project/img/@hero-background`}
|
||||
language="text"
|
||||
filename="CDN Alias URL"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="info">
|
||||
<strong>Pro Tip:</strong> Use aliases for brand assets like <InlineCode>@logo</InlineCode>, <InlineCode>@brand-colors</InlineCode>. Reference them in generations without remembering UUIDs.
|
||||
</TipBox>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">
|
||||
Remove an alias by setting it to null:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X PUT https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"alias": null}'`}
|
||||
language="bash"
|
||||
filename="Remove Alias"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="deleting-images" className="mb-12">
|
||||
<SectionHeader level={2} id="deleting-images">
|
||||
Deleting Images
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Delete an image by ID or alias. This permanently removes the image from storage.
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X DELETE https://api.banatie.app/api/v1/images/550e8400-e29b-41d4-a716-446655440000 \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
language="bash"
|
||||
filename="Delete Image"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="warning">
|
||||
Deletion is permanent. The image file and all references are removed.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { DocsSidebar } from '@/components/docs/layout/DocsSidebar';
|
||||
import { ThreeColumnLayout } from '@/components/layout/ThreeColumnLayout';
|
||||
import { ApiKeyWidget } from '@/components/shared/ApiKeyWidget/apikey-widget';
|
||||
import { ApiKeyProvider } from '@/components/shared/ApiKeyWidget/apikey-context';
|
||||
import { PageProvider } from '@/contexts/page-context';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'API', href: '/docs/' },
|
||||
{ label: 'SDK', href: '#', disabled: true },
|
||||
{ label: 'MCP', href: '#', disabled: true },
|
||||
{ label: 'CLI', href: '#', disabled: true },
|
||||
{ label: 'Lab', href: '#', disabled: true },
|
||||
];
|
||||
|
||||
export default function DocsRootLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<ApiKeyProvider>
|
||||
<PageProvider
|
||||
navItems={navItems}
|
||||
currentPath={pathname}
|
||||
rightSlot={<ApiKeyWidget />}
|
||||
>
|
||||
<ThreeColumnLayout
|
||||
left={
|
||||
<div className="border-r border-white/10 bg-slate-950/50 backdrop-blur-sm sticky top-12 h-[calc(100vh-3rem)] overflow-y-auto">
|
||||
<DocsSidebar currentPath={pathname} />
|
||||
</div>
|
||||
}
|
||||
center={children}
|
||||
/>
|
||||
</PageProvider>
|
||||
</ApiKeyProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { Table } from '@/components/docs/shared/Table';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema } from '@/config/docs-schema';
|
||||
import { Hero, SectionHeader, InlineCode } from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['live-urls'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
{ name: 'Live URLs', path: '/docs/live-urls/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'the-concept', text: 'The Concept', level: 2 },
|
||||
{ id: 'url-format', text: 'URL Format', level: 2 },
|
||||
{ id: 'try-it', text: 'Try It', level: 2 },
|
||||
{ id: 'placeholder-images', text: 'Placeholder Images', level: 2 },
|
||||
{ id: 'caching-behavior', text: 'Caching Behavior', level: 2 },
|
||||
{ id: 'scopes', text: 'Scopes', level: 2 },
|
||||
{ id: 'rate-limits', text: 'Rate Limits', level: 2 },
|
||||
{ id: 'use-cases', text: 'Use Cases', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function LiveUrlsPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<DocPage
|
||||
breadcrumbItems={[{ label: 'Documentation', href: '/docs/' }, { label: 'Live URLs' }]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/api/live-scopes/',
|
||||
title: 'API Reference: Live Scopes',
|
||||
description: 'Manage scopes and generation limits.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/generation/',
|
||||
title: 'Image Generation',
|
||||
description: 'Full control via the generations API.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Live URLs"
|
||||
subtitle="Generate images directly from URL parameters. No API calls needed — just use the URL in your HTML."
|
||||
/>
|
||||
|
||||
<section id="the-concept" className="mb-12">
|
||||
<SectionHeader level={2} id="the-concept">
|
||||
The Concept
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-4">
|
||||
Put your prompt in a URL. Use it directly in an{' '}
|
||||
<InlineCode><img src="..."></InlineCode> tag.
|
||||
</p>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
First request generates the image. All subsequent requests serve it from cache
|
||||
instantly.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="url-format" className="mb-12">
|
||||
<SectionHeader level={2} id="url-format">
|
||||
URL Format
|
||||
</SectionHeader>
|
||||
|
||||
<CodeBlock
|
||||
code={`https://cdn.banatie.app/{org}/{project}/live/{scope}?prompt=...&aspectRatio=...`}
|
||||
language="text"
|
||||
filename="Live URL Format"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Query parameters:</p>
|
||||
|
||||
<Table
|
||||
headers={['Parameter', 'Required', 'Description']}
|
||||
rows={[
|
||||
[
|
||||
<InlineCode key="p">prompt</InlineCode>,
|
||||
<span key="r" className="text-green-400">
|
||||
Yes
|
||||
</span>,
|
||||
'Text description of the image to generate',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">aspectRatio</InlineCode>,
|
||||
<span key="r" className="text-gray-500">
|
||||
No
|
||||
</span>,
|
||||
'Image ratio: 1:1, 16:9, 9:16, 3:2 (default: 16:9)',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">template</InlineCode>,
|
||||
<span key="r" className="text-gray-500">
|
||||
No
|
||||
</span>,
|
||||
'Enhancement template to use',
|
||||
],
|
||||
[
|
||||
<InlineCode key="p">autoEnhance</InlineCode>,
|
||||
<span key="r" className="text-gray-500">
|
||||
No
|
||||
</span>,
|
||||
'Enable prompt enhancement (default: true)',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="try-it" className="mb-12">
|
||||
<SectionHeader level={2} id="try-it">
|
||||
Try It
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">Open this URL in your browser:</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`https://cdn.banatie.app/my-org/my-project/live/demo?prompt=a+friendly+robot+waving+hello&aspectRatio=16:9`}
|
||||
language="text"
|
||||
filename="Example Live URL"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">Or use it directly in HTML:</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`<img
|
||||
src="https://cdn.banatie.app/my-org/my-project/live/hero?prompt=mountain+landscape+at+sunset&aspectRatio=16:9"
|
||||
alt="Mountain landscape"
|
||||
/>`}
|
||||
language="html"
|
||||
filename="HTML Usage"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="caching-behavior" className="mb-12">
|
||||
<SectionHeader level={2} id="caching-behavior">
|
||||
Caching Behavior
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
The response includes an <InlineCode>X-Cache-Status</InlineCode> header:
|
||||
</p>
|
||||
|
||||
<Table
|
||||
headers={['Status', 'Meaning', 'Response Time']}
|
||||
rows={[
|
||||
[
|
||||
<InlineCode key="s" color="success">
|
||||
HIT
|
||||
</InlineCode>,
|
||||
'Image served from cache',
|
||||
'Instant (milliseconds)',
|
||||
],
|
||||
[<InlineCode key="s">MISS</InlineCode>, 'New image generated', 'A few seconds'],
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
Cache hits are unlimited and don't count toward rate limits. Only new generations (cache
|
||||
MISS) are rate limited.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="scopes" className="mb-12">
|
||||
<SectionHeader level={2} id="scopes">
|
||||
Scopes
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Scopes organize your live generations. Each scope has its own generation limit.
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`# Different scopes for different purposes
|
||||
https://cdn.banatie.app/my-org/my-project/live/hero-section?prompt=...
|
||||
https://cdn.banatie.app/my-org/my-project/live/product-gallery?prompt=...
|
||||
https://cdn.banatie.app/my-org/my-project/live/blog-images?prompt=...`}
|
||||
language="text"
|
||||
filename="Scope Examples"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
Scopes are auto-created on first use. You can also pre-configure them via the API to set
|
||||
custom limits.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* <section id="rate-limits" className="mb-12">
|
||||
<SectionHeader level={2} id="rate-limits">
|
||||
Rate Limits
|
||||
</SectionHeader>
|
||||
|
||||
<Table
|
||||
headers={['Limit Type', 'Value', 'Notes']}
|
||||
rows={[
|
||||
[
|
||||
'Per IP',
|
||||
<span key="v" className="text-amber-400">
|
||||
10 new generations/hour
|
||||
</span>,
|
||||
'Only applies to cache MISS',
|
||||
],
|
||||
[
|
||||
'Per Scope',
|
||||
<span key="v" className="text-amber-400">
|
||||
30 generations
|
||||
</span>,
|
||||
'Configurable via API',
|
||||
],
|
||||
[
|
||||
'Cache Hits',
|
||||
<span key="v" className="text-green-400">
|
||||
Unlimited
|
||||
</span>,
|
||||
'No limits on cached images',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="info">
|
||||
Rate limits protect the service from abuse. For high-volume needs, use the generations
|
||||
API directly.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section> */}
|
||||
|
||||
<section id="use-cases" className="mb-12">
|
||||
<SectionHeader level={2} id="use-cases">
|
||||
Use Cases
|
||||
</SectionHeader>
|
||||
|
||||
<ul className="space-y-4 text-gray-300">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-purple-400 mt-1">•</span>
|
||||
<div>
|
||||
<strong className="text-white">Static HTML & serverless sites</strong>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Deploy HTML pages without configuring asset hosting or CDN infrastructure. Images
|
||||
are served directly from Banatie's edge network.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-purple-400 mt-1">•</span>
|
||||
<div>
|
||||
<strong className="text-white">AI-assisted development</strong>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Enable AI coding assistants to generate complete HTML or JSX with contextual
|
||||
images in a single pass—no asset pipeline required.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-purple-400 mt-1">•</span>
|
||||
<div>
|
||||
<strong className="text-white">Rapid prototyping</strong>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Test different visuals without writing backend code.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-purple-400 mt-1">•</span>
|
||||
<div>
|
||||
<strong className="text-white">AI placeholder images</strong>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Replace gray boxes and random stock photos with contextual AI images. Perfect for
|
||||
prototypes, client demos, and design mockups.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-purple-400 mt-1">•</span>
|
||||
<div>
|
||||
<strong className="text-white">Personalized content</strong>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Generate unique images based on user data or preferences for dynamic,
|
||||
individualized experiences.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="placeholder-images" className="mb-12">
|
||||
<TipBox variant="protip">
|
||||
Use Live URLs as intelligent placeholder images during development. Generate contextual
|
||||
visuals that match your design intent—no more gray boxes or random stock photos.
|
||||
</TipBox>
|
||||
|
||||
<p className="text-gray-400 text-sm mt-6 mb-4">Common placeholder configurations:</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`<!-- Avatar placeholder (200×200) -->
|
||||
<img src="https://cdn.banatie.app/.../live/avatars?prompt=professional+headshot&aspectRatio=1:1" />
|
||||
|
||||
<!-- Thumbnail placeholder (300×200) -->
|
||||
<img src="https://cdn.banatie.app/.../live/thumbs?prompt=product+photo&aspectRatio=3:2" />
|
||||
|
||||
<!-- Hero placeholder (1200×630) -->
|
||||
<img src="https://cdn.banatie.app/.../live/hero?prompt=modern+office+interior&aspectRatio=1200:630" />
|
||||
|
||||
<!-- Card image placeholder (400×300) -->
|
||||
<img src="https://cdn.banatie.app/.../live/cards?prompt=abstract+gradient+background&aspectRatio=4:3" />`}
|
||||
language="html"
|
||||
filename="Placeholder Examples"
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<TipBox variant="compact" type="info">
|
||||
For dark mode interfaces, include "dark theme" or "dark background" in your prompt.
|
||||
</TipBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { TipBox } from '@/components/docs/shared/TipBox';
|
||||
import { CodeBlock } from '@/components/docs/shared/CodeBlock';
|
||||
import { DocPage } from '@/components/docs/layout/DocPage';
|
||||
import { JsonLd } from '@/components/seo/JsonLd';
|
||||
import { createDocsMetadata, DOCS_PAGES } from '@/config/docs-seo';
|
||||
import { createBreadcrumbSchema, createTechArticleSchema, HOW_TO_SCHEMA } from '@/config/docs-schema';
|
||||
import {
|
||||
Hero,
|
||||
SectionHeader,
|
||||
ResponseBlock,
|
||||
LinkCard,
|
||||
LinkCardGrid,
|
||||
} from '@/components/docs/blocks';
|
||||
|
||||
const PAGE = DOCS_PAGES['getting-started'];
|
||||
|
||||
export const metadata: Metadata = createDocsMetadata(PAGE);
|
||||
|
||||
const breadcrumbSchema = createBreadcrumbSchema([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Documentation', path: '/docs/' },
|
||||
]);
|
||||
|
||||
const articleSchema = createTechArticleSchema(PAGE);
|
||||
|
||||
const tocItems = [
|
||||
{ id: 'what-is-banatie', text: 'What is Banatie?', level: 2 },
|
||||
{ id: 'your-first-image', text: 'Your First Image', level: 2 },
|
||||
{ id: 'production-ready', text: 'Production Ready', level: 2 },
|
||||
{ id: 'live-urls', text: 'Live URLs', level: 2 },
|
||||
{ id: 'get-your-api-key', text: 'Get Your API Key', level: 2 },
|
||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
||||
];
|
||||
|
||||
export default function GettingStartedPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<JsonLd data={articleSchema} />
|
||||
<JsonLd data={HOW_TO_SCHEMA} />
|
||||
<DocPage
|
||||
breadcrumbItems={[
|
||||
{ label: 'Documentation', href: '/docs/' },
|
||||
{ label: 'Getting Started' },
|
||||
]}
|
||||
tocItems={tocItems}
|
||||
nextSteps={{
|
||||
links: [
|
||||
{
|
||||
href: '/docs/generation/',
|
||||
title: 'Image Generation',
|
||||
description: 'Aspect ratios, prompt templates, using references.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/images/',
|
||||
title: 'Working with Images',
|
||||
description: 'Upload your own, organize with aliases.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
{
|
||||
href: '/docs/live-urls/',
|
||||
title: 'Live URLs',
|
||||
description: 'Generate images directly from URL parameters.',
|
||||
accent: 'primary',
|
||||
},
|
||||
{
|
||||
href: '/docs/api/',
|
||||
title: 'API Reference',
|
||||
description: 'Full endpoint documentation.',
|
||||
accent: 'secondary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Hero
|
||||
title="Get Started"
|
||||
subtitle="Generate your first AI image in a few simple steps."
|
||||
/>
|
||||
|
||||
<section id="what-is-banatie" className="mb-12">
|
||||
<SectionHeader level={2} id="what-is-banatie">
|
||||
What is Banatie?
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-4">
|
||||
Banatie is an image generation API for developers. Send a text prompt, get a production-ready image delivered via CDN.
|
||||
</p>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Simple REST API. Optimized AI models that deliver consistent results. Images ready for production use immediately.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="your-first-image" className="mb-12">
|
||||
<SectionHeader level={2} id="your-first-image">
|
||||
Your First Image
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Once you have your API key, generate an image with a single request:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://api.banatie.app/api/v1/generations \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{"prompt": "a friendly robot waving hello"}'`}
|
||||
language="bash"
|
||||
filename="Generate Image"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-4">
|
||||
That's it. The response contains your image:
|
||||
</p>
|
||||
|
||||
<ResponseBlock
|
||||
status="success"
|
||||
statusCode={200}
|
||||
statusLabel="200 OK"
|
||||
content={`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": "success",
|
||||
"outputImage": {
|
||||
"storageUrl": "https://cdn.banatie.app/my-org/my-project/img/8a3b2c1d-4e5f-6789-abcd-ef0123456789",
|
||||
"width": 1792,
|
||||
"height": 1008
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
Open <code className="text-purple-400">storageUrl</code> in your browser — there's your robot.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="production-ready" className="mb-12">
|
||||
<SectionHeader level={2} id="production-ready">
|
||||
Production Ready
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
The image URL is permanent and served via global CDN. What this means for you:
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3 text-gray-300">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-purple-400 mt-1">•</span>
|
||||
<span><strong className="text-white">Fast access</strong> — images load in milliseconds</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-purple-400 mt-1">•</span>
|
||||
<span><strong className="text-white">Edge cached</strong> — served from locations closest to your users</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-purple-400 mt-1">•</span>
|
||||
<span><strong className="text-white">Global distribution</strong> — works fast everywhere in the world</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
One request. Production-ready result. Drop the URL into your app and ship.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="live-urls" className="mb-12">
|
||||
<SectionHeader level={2} id="live-urls">
|
||||
Live URLs
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
Want to skip the API call entirely? Generate images directly from a URL:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
code={`https://cdn.banatie.app/my-org/my-project/live/demo?prompt=a+friendly+robot+waving+hello`}
|
||||
language="text"
|
||||
filename="Live URL"
|
||||
/>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6 mb-6">
|
||||
Put this in an <code className="text-purple-400"><img src="..."></code> tag. First request generates the image, all subsequent requests serve it from cache instantly.
|
||||
</p>
|
||||
|
||||
<TipBox variant="compact" type="info">
|
||||
Perfect for placeholders, dynamic content, and rapid prototyping.
|
||||
</TipBox>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed mt-6">
|
||||
<a href="/docs/live-urls/" className="text-purple-400 hover:underline">Learn more about Live URLs →</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="get-your-api-key" className="mb-12">
|
||||
<SectionHeader level={2} id="get-your-api-key">
|
||||
Get Your API Key
|
||||
</SectionHeader>
|
||||
<p className="text-gray-300 leading-relaxed mb-6">
|
||||
We're currently in early access. API keys are issued personally.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 text-gray-300 mb-6">
|
||||
<p><strong className="text-white">To request access:</strong></p>
|
||||
<ol className="list-decimal list-inside space-y-2 pl-4">
|
||||
<li>Go to <a href="https://banatie.app" className="text-purple-400 hover:underline">banatie.app</a></li>
|
||||
<li>Enter your email in the signup form</li>
|
||||
<li>We'll send your API key within 24 hours</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</DocPage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import Image from 'next/image';
|
||||
import { Footer } from '@/components/shared/Footer';
|
||||
|
||||
export default function AppsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Scrollable Header (NOT sticky) */}
|
||||
<header className="z-10 bg-slate-900/80 backdrop-blur-md border-b border-white/5">
|
||||
<nav className="max-w-7xl mx-auto px-4 sm:px-6 py-2 sm:py-3 flex justify-between items-center h-12 sm:h-14 md:h-16">
|
||||
<a href="/" className="h-full flex items-center">
|
||||
<Image
|
||||
src="/banatie-logo-horisontal.png"
|
||||
alt="Banatie Logo"
|
||||
width={150}
|
||||
height={40}
|
||||
priority
|
||||
className="h-8 sm:h-10 md:h-full w-auto object-contain"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="/#get-access"
|
||||
className="text-xs sm:text-sm text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Get Access
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
# Lab Section Design System
|
||||
|
||||
The Lab section is a production-ready UI interface for interacting with the Banatie API service. It provides a clean, work-focused experience for image generation, gallery browsing, alias management, and flow control.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
apps/landing/src/
|
||||
├── app/(lab)/
|
||||
│ ├── CLAUDE.md # This design documentation
|
||||
│ ├── layout.tsx # Root lab layout with scroll context
|
||||
│ └── lab/
|
||||
│ ├── layout.tsx # Sub-layout with PageProvider
|
||||
│ ├── page.tsx # Redirect to /lab/generate
|
||||
│ ├── generate/page.tsx # Generation workbench
|
||||
│ ├── images/page.tsx # Image gallery browser
|
||||
│ ├── live/page.tsx # Live generation testing
|
||||
│ └── upload/page.tsx # File upload interface
|
||||
│
|
||||
├── components/layout/lab/
|
||||
│ ├── LabLayout.tsx # Main layout (sidebar + content + footer)
|
||||
│ ├── LabSidebar.tsx # Left filter panel
|
||||
│ └── LabFooter.tsx # Contextual footer with links
|
||||
│
|
||||
├── components/lab/
|
||||
│ ├── GenerateFormPlaceholder.tsx # Generation form component
|
||||
│ └── FilterPlaceholder.tsx # Reusable filter checkbox/radio
|
||||
│
|
||||
└── contexts/
|
||||
└── lab-scroll-context.tsx # Scroll state for header collapse
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Work-focused, not marketing** - Small typography, efficient spacing
|
||||
2. **Clean and functional** - Like Google AI Studio
|
||||
3. **Dual color system** - Zinc for layout, Slate for forms
|
||||
4. **Consistent icons** - Lucide React only, no emojis
|
||||
5. **Responsive** - Works on 768px to 1920px+ screens
|
||||
|
||||
---
|
||||
|
||||
## Color System
|
||||
|
||||
### Layout/Chrome → Zinc (Neutral Gray)
|
||||
Used for sidebar, footer, and layout borders:
|
||||
```css
|
||||
bg-zinc-950 /* Sidebar background */
|
||||
bg-zinc-950/50 /* Footer background, layout wrappers */
|
||||
border-zinc-800 /* Layout borders, dividers */
|
||||
text-zinc-400 /* Sidebar text */
|
||||
text-zinc-500 /* Footer text, muted */
|
||||
```
|
||||
|
||||
### Forms/Cards → Slate (Original Style)
|
||||
Used for cards, inputs, and interactive UI elements:
|
||||
```css
|
||||
bg-slate-900/80 /* Card backgrounds */
|
||||
bg-slate-900/50 /* Empty states, lighter cards */
|
||||
bg-slate-800 /* Input backgrounds, secondary surfaces */
|
||||
border-slate-700 /* Card borders, input borders */
|
||||
text-gray-400 /* Labels, secondary text */
|
||||
text-gray-500 /* Placeholders, hints */
|
||||
```
|
||||
|
||||
### Accent Colors (Purple/Cyan)
|
||||
```css
|
||||
/* Primary gradient */
|
||||
bg-gradient-to-r from-purple-600 to-cyan-600
|
||||
hover:from-purple-500 hover:to-cyan-500
|
||||
shadow-lg shadow-purple-900/30
|
||||
focus:ring-2 focus:ring-purple-500
|
||||
|
||||
/* Single-color accents */
|
||||
text-purple-400 /* Links, interactive text */
|
||||
|
||||
/* Info banners */
|
||||
bg-purple-900/10 border-purple-700/50
|
||||
```
|
||||
|
||||
### Status Colors
|
||||
```css
|
||||
/* Success */ bg-emerald-500/10 border-emerald-500/30 text-emerald-400
|
||||
/* Warning */ bg-amber-500/10 border-amber-500/30 text-amber-400
|
||||
/* Error */ bg-red-500/10 border-red-500/30 text-red-400
|
||||
/* Info */ bg-purple-900/10 border-purple-700/50 text-purple-400
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typography Scale
|
||||
|
||||
### Headings (Practical Sizes)
|
||||
```tsx
|
||||
// Page Title (only on main pages)
|
||||
text-lg font-semibold text-white
|
||||
|
||||
// Section Title
|
||||
text-base font-semibold text-white
|
||||
|
||||
// Card Title
|
||||
text-sm font-medium text-white
|
||||
```
|
||||
|
||||
### Body Text
|
||||
```tsx
|
||||
// Primary body
|
||||
text-sm text-gray-300
|
||||
|
||||
// Secondary/descriptions
|
||||
text-sm text-gray-400
|
||||
|
||||
// Small text (hints, metadata)
|
||||
text-xs text-gray-500
|
||||
|
||||
// Labels (form fields)
|
||||
text-xs font-medium text-gray-400
|
||||
```
|
||||
|
||||
### Interactive
|
||||
```tsx
|
||||
// Button text
|
||||
text-sm font-semibold
|
||||
|
||||
// Links
|
||||
text-sm text-purple-400 hover:text-purple-300
|
||||
|
||||
// Badge/count
|
||||
text-xs text-gray-600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spacing System
|
||||
|
||||
### Padding
|
||||
```tsx
|
||||
// Cards
|
||||
p-4 md:p-5
|
||||
|
||||
// Compact cards
|
||||
p-3
|
||||
|
||||
// Form inputs
|
||||
px-3 py-2
|
||||
|
||||
// Buttons (primary)
|
||||
px-4 py-2.5
|
||||
|
||||
// Buttons (secondary)
|
||||
px-3 py-2
|
||||
```
|
||||
|
||||
### Section Spacing
|
||||
```tsx
|
||||
// Page padding
|
||||
p-4 md:p-6
|
||||
|
||||
// Between sections
|
||||
space-y-4
|
||||
|
||||
// Between cards in grid
|
||||
gap-3 md:gap-4
|
||||
|
||||
// Between form fields
|
||||
gap-2
|
||||
|
||||
// Label to input
|
||||
mb-1.5
|
||||
```
|
||||
|
||||
### Border Radius
|
||||
```tsx
|
||||
rounded-lg /* Standard (inputs, small cards) */
|
||||
rounded-xl /* Medium (cards) */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Page Header
|
||||
```tsx
|
||||
<header className="pb-3 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-purple-400" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-white">Generate</h1>
|
||||
<p className="text-xs text-gray-400">Create AI images from text prompts</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
```
|
||||
|
||||
### Form Card (Slate)
|
||||
```tsx
|
||||
<section className="p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl">
|
||||
{/* content */}
|
||||
</section>
|
||||
```
|
||||
|
||||
### Form Input (Slate)
|
||||
```tsx
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-400">Field Label</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Enter value..."
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Textarea (Slate)
|
||||
```tsx
|
||||
<textarea
|
||||
className="w-full px-3 py-2.5 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
rows={4}
|
||||
/>
|
||||
```
|
||||
|
||||
### Primary Button
|
||||
```tsx
|
||||
<button className="px-4 py-2 text-sm rounded-lg bg-gradient-to-r from-purple-600 to-cyan-600 text-white font-semibold hover:from-purple-500 hover:to-cyan-500 transition-all shadow-lg shadow-purple-900/30 focus:ring-2 focus:ring-purple-500 flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Generate
|
||||
</button>
|
||||
```
|
||||
|
||||
### Secondary Button (Slate)
|
||||
```tsx
|
||||
<button className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-gray-400 hover:text-white hover:bg-slate-700 transition-colors flex items-center justify-center gap-1.5 focus:ring-2 focus:ring-purple-500">
|
||||
<Settings className="w-4 h-4" />
|
||||
Configure
|
||||
</button>
|
||||
```
|
||||
|
||||
### Empty State (Slate)
|
||||
```tsx
|
||||
<div className="p-6 bg-slate-900/50 backdrop-blur-sm border border-slate-700 rounded-lg text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 flex items-center justify-center bg-slate-800 rounded-lg">
|
||||
<ImageOff className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-white mb-1">No results yet</h3>
|
||||
<p className="text-xs text-gray-400">Generated images will appear here</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Info Banner
|
||||
```tsx
|
||||
<div className="p-3 bg-purple-900/10 border border-purple-700/50 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-purple-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-gray-300">
|
||||
<span className="font-medium text-white">Lab Mode:</span> Experimental features enabled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Icon System (Lucide React)
|
||||
|
||||
### Standard Sizes
|
||||
```tsx
|
||||
// Inline with text
|
||||
<Icon className="w-4 h-4" />
|
||||
|
||||
// Small (badges)
|
||||
<Icon className="w-3 h-3" />
|
||||
|
||||
// Medium (empty states)
|
||||
<Icon className="w-6 h-6" />
|
||||
|
||||
// With margin (before text)
|
||||
<Icon className="w-4 h-4 mr-1.5" />
|
||||
```
|
||||
|
||||
### Recommended Icons
|
||||
```tsx
|
||||
// Navigation
|
||||
import { Home, Image, Upload, Zap, Settings } from 'lucide-react';
|
||||
|
||||
// Actions
|
||||
import { Plus, X, Download, Share2, Copy, Trash2, Edit3, MoreVertical } from 'lucide-react';
|
||||
|
||||
// Filters
|
||||
import { Activity, Calendar, Palette, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
|
||||
// Status
|
||||
import { CheckCircle2, AlertCircle, XCircle, Info, Loader2 } from 'lucide-react';
|
||||
|
||||
// Media
|
||||
import { ImageOff, FileImage, Sparkles } from 'lucide-react';
|
||||
|
||||
// Form
|
||||
import { Search, Filter, SlidersHorizontal } from 'lucide-react';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
```tsx
|
||||
// Base: < 768px (mobile) - single column, no sidebar
|
||||
// md: >= 768px (tablet) - 2 columns, no sidebar
|
||||
// lg: >= 1024px (desktop) - sidebar visible, 2-3 columns
|
||||
// xl: >= 1280px (large desktop) - optimal spacing, 3 columns
|
||||
```
|
||||
|
||||
### Sidebar Behavior
|
||||
```tsx
|
||||
// Hidden on mobile/tablet, visible lg+
|
||||
hidden lg:block w-64
|
||||
```
|
||||
|
||||
### Content Grid
|
||||
```tsx
|
||||
// Images: 1 col mobile, 2 col tablet, 3 col desktop
|
||||
grid-cols-1 md:grid-cols-2 xl:grid-cols-3
|
||||
|
||||
// Form fields
|
||||
grid-cols-1 md:grid-cols-3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### DO
|
||||
- Use **zinc** for layout (sidebar, footer, layout borders)
|
||||
- Use **slate** for forms (cards, inputs, empty states)
|
||||
- Use text-sm/text-xs for most text
|
||||
- Use Lucide icons exclusively
|
||||
- Keep spacing tight (p-3 to p-5)
|
||||
- Add focus:ring-2 focus:ring-purple-500 to all interactive elements
|
||||
- Use transitions (transition-colors, transition-all)
|
||||
|
||||
### DON'T
|
||||
- Use emojis anywhere in the UI
|
||||
- Use marketing-size headings (text-3xl+)
|
||||
- Use generous spacing (p-8+, py-12+)
|
||||
- Mix zinc and slate inconsistently
|
||||
- Forget aria-label on icon-only buttons
|
||||
|
||||
---
|
||||
|
||||
## Layout Architecture
|
||||
|
||||
The Lab uses a scroll-aware header system:
|
||||
|
||||
1. **LabScrollProvider** wraps the entire section
|
||||
2. **LabLayout** detects scroll > 50px in content area
|
||||
3. When scrolled, header collapses (h-16 → h-0)
|
||||
4. SubsectionNav becomes the top element
|
||||
5. Content height adjusts: `h-[calc(100vh-7rem)]` → `h-[calc(100vh-3rem)]`
|
||||
|
||||
```tsx
|
||||
// Layout hierarchy
|
||||
(lab)/layout.tsx → LabScrollProvider + LabHeader
|
||||
└── lab/layout.tsx → PageProvider (SubsectionNav)
|
||||
└── LabLayout.tsx → ThreeColumnLayout (sidebar + content + footer)
|
||||
└── page.tsx → Actual page content
|
||||
```
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import { GenerateFormPlaceholder } from '@/components/lab/GenerateFormPlaceholder';
|
||||
|
||||
const GeneratePage = () => {
|
||||
return <GenerateFormPlaceholder />;
|
||||
};
|
||||
|
||||
export default GeneratePage;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import { Image, ImageOff } from 'lucide-react';
|
||||
|
||||
const ImagesPage = () => {
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
{/* Page Header */}
|
||||
<header className="pb-3 border-b border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image className="w-5 h-5 text-purple-400" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-white">Images</h1>
|
||||
<p className="text-xs text-gray-400">Browse and manage your generated images</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Empty State Placeholder */}
|
||||
<div className="p-6 bg-slate-900/50 backdrop-blur-sm border border-slate-700 rounded-lg text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 flex items-center justify-center bg-slate-800 rounded-lg">
|
||||
<ImageOff className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-white mb-1">Image Browser</h2>
|
||||
<p className="text-xs text-gray-400">Component will be implemented here</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImagesPage;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* Lab Section Layout
|
||||
*
|
||||
* Code Style:
|
||||
* - Use `const` arrow function components (not function declarations)
|
||||
* - Use `type` instead of `interface` for type definitions
|
||||
* - Early returns for conditionals
|
||||
* - No inline comments (JSDoc headers only)
|
||||
* - Tailwind classes only
|
||||
*
|
||||
* Structure:
|
||||
* - Layout components: src/components/layout/lab/
|
||||
* - Feature components: src/components/lab/
|
||||
* - Pages: src/app/lab/{section}/page.tsx
|
||||
*
|
||||
* Sub-navigation items:
|
||||
* - /lab/generate - Image generation
|
||||
* - /lab/images - Image library browser
|
||||
* - /lab/live - Live generation testing
|
||||
* - /lab/upload - File upload interface
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ApiKeyWidget } from '@/components/shared/ApiKeyWidget/apikey-widget';
|
||||
import { ApiKeyProvider } from '@/components/shared/ApiKeyWidget/apikey-context';
|
||||
import { PageProvider } from '@/contexts/page-context';
|
||||
import { LabLayout } from '@/components/layout/lab/LabLayout';
|
||||
|
||||
type LabLayoutWrapperProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Generate', href: '/lab/generate' },
|
||||
{ label: 'Images', href: '/lab/images' },
|
||||
{ label: 'Live', href: '/lab/live' },
|
||||
{ label: 'Upload', href: '/lab/upload' },
|
||||
];
|
||||
|
||||
const LabLayoutWrapper = ({ children }: LabLayoutWrapperProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<ApiKeyProvider>
|
||||
<PageProvider navItems={navItems} currentPath={pathname} rightSlot={<ApiKeyWidget />}>
|
||||
<LabLayout>{children}</LabLayout>
|
||||
</PageProvider>
|
||||
</ApiKeyProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabLayoutWrapper;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import { Zap, Radio } from 'lucide-react';
|
||||
|
||||
const LivePage = () => {
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
{/* Page Header */}
|
||||
<header className="pb-3 border-b border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-purple-400" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-white">Live</h1>
|
||||
<p className="text-xs text-gray-400">Real-time testing and experimentation</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Live Testing Placeholder */}
|
||||
<div className="p-6 bg-slate-900/50 backdrop-blur-sm border border-slate-700 rounded-lg text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 flex items-center justify-center bg-slate-800 rounded-lg">
|
||||
<Radio className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-white mb-1">Live Testing Interface</h2>
|
||||
<p className="text-xs text-gray-400">Component will be implemented here</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LivePage;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const LabPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/lab/generate');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default LabPage;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import { Upload, UploadCloud } from 'lucide-react';
|
||||
|
||||
const UploadPage = () => {
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
{/* Page Header */}
|
||||
<header className="pb-3 border-b border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-purple-400" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-white">Upload</h1>
|
||||
<p className="text-xs text-gray-400">Upload and manage reference images</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Upload Dropzone Placeholder */}
|
||||
<div className="p-6 bg-slate-900/50 backdrop-blur-sm border border-slate-700 border-dashed rounded-lg text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 flex items-center justify-center bg-slate-800 rounded-lg">
|
||||
<UploadCloud className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-white mb-1">Upload Interface</h2>
|
||||
<p className="text-xs text-gray-400">Drag and drop files or click to browse</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadPage;
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { LabScrollProvider, useLabScroll } from '@/contexts/lab-scroll-context';
|
||||
|
||||
const LabHeader = () => {
|
||||
const { isScrolled } = useLabScroll();
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`
|
||||
relative z-10 border-b border-white/10 backdrop-blur-sm shrink-0
|
||||
transition-all duration-300 ease-in-out
|
||||
${isScrolled ? 'h-0 opacity-0 overflow-hidden border-b-0' : 'h-16 opacity-100'}
|
||||
`}
|
||||
>
|
||||
<nav className="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center h-16">
|
||||
<div className="h-full flex items-center">
|
||||
<Image
|
||||
src="/banatie-logo-horisontal.png"
|
||||
alt="Banatie Logo"
|
||||
width={150}
|
||||
height={40}
|
||||
priority
|
||||
className="h-full w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
href="#waitlist"
|
||||
className="text-sm text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Join Beta
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default function LabLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<LabScrollProvider>
|
||||
<div className="h-screen overflow-hidden flex flex-col">
|
||||
<LabHeader />
|
||||
<div className="flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</LabScrollProvider>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 377 KiB |
|
|
@ -1,62 +0,0 @@
|
|||
import { Terminal } from 'lucide-react';
|
||||
|
||||
export function ApiExampleSection() {
|
||||
return (
|
||||
<section className="py-16 px-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-gradient-to-b from-indigo-500/10 to-[rgba(30,27,75,0.4)] border border-indigo-500/20 backdrop-blur-[10px] rounded-2xl p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-indigo-500/20 to-purple-500/20 flex items-center justify-center">
|
||||
<Terminal className="w-5 h-5 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">One request. Production-ready URL.</h2>
|
||||
<p className="text-gray-400 text-sm">Simple REST API that handles everything</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/50 border border-indigo-500/20 rounded-lg p-4 font-mono text-sm overflow-x-auto mb-4">
|
||||
<div className="text-gray-500 mb-2"># Generate an image</div>
|
||||
<span className="text-cyan-400">curl</span>{' '}
|
||||
<span className="text-gray-300">-X POST https://api.banatie.app/v1/generate \</span>
|
||||
<br />
|
||||
<span className="text-gray-300 ml-4">-H</span>{' '}
|
||||
<span className="text-green-400">"Authorization: Bearer $API_KEY"</span>{' '}
|
||||
<span className="text-gray-300">\</span>
|
||||
<br />
|
||||
<span className="text-gray-300 ml-4">-d</span>{' '}
|
||||
<span className="text-green-400">
|
||||
'{`{"prompt": "modern office interior, natural light"}`}'
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/50 border border-indigo-500/20 rounded-lg p-4 font-mono text-sm overflow-x-auto">
|
||||
<div className="text-gray-500 mb-2"># Response</div>
|
||||
<span className="text-gray-300">{'{'}</span>
|
||||
<br />
|
||||
<span className="text-purple-400 ml-4">"url"</span>
|
||||
<span className="text-gray-300">:</span>{' '}
|
||||
<span className="text-green-400">
|
||||
"https://cdn.banatie.app/img/a7x2k9.png"
|
||||
</span>
|
||||
<span className="text-gray-300">,</span>
|
||||
<br />
|
||||
<span className="text-purple-400 ml-4">"enhanced_prompt"</span>
|
||||
<span className="text-gray-300">:</span>{' '}
|
||||
<span className="text-green-400">"A photorealistic modern office..."</span>
|
||||
<span className="text-gray-300">,</span>
|
||||
<br />
|
||||
<span className="text-purple-400 ml-4">"generation_time"</span>
|
||||
<span className="text-gray-300">:</span> <span className="text-yellow-400">12.4</span>
|
||||
<br />
|
||||
<span className="text-gray-300">{'}'}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm mt-4 text-center">
|
||||
CDN-cached, optimized, ready to use. No download, no upload, no extra steps.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
const blobs = [
|
||||
{
|
||||
className: 'w-[600px] h-[600px] top-[-200px] right-[-100px]',
|
||||
gradient: 'rgba(139, 92, 246, 0.3)',
|
||||
},
|
||||
{
|
||||
className: 'w-[500px] h-[500px] top-[800px] left-[-150px]',
|
||||
gradient: 'rgba(99, 102, 241, 0.25)',
|
||||
},
|
||||
{
|
||||
className: 'w-[400px] h-[400px] top-[1600px] right-[-100px]',
|
||||
gradient: 'rgba(236, 72, 153, 0.2)',
|
||||
},
|
||||
{
|
||||
className: 'w-[550px] h-[550px] top-[2400px] left-[-200px]',
|
||||
gradient: 'rgba(34, 211, 238, 0.15)',
|
||||
},
|
||||
{
|
||||
className: 'w-[450px] h-[450px] top-[3200px] right-[-150px]',
|
||||
gradient: 'rgba(139, 92, 246, 0.25)',
|
||||
},
|
||||
{
|
||||
className: 'w-[500px] h-[500px] top-[4000px] left-[-100px]',
|
||||
gradient: 'rgba(99, 102, 241, 0.2)',
|
||||
},
|
||||
];
|
||||
|
||||
export function BackgroundBlobs() {
|
||||
return (
|
||||
<div className="w-full h-full absolute overflow-hidden">
|
||||
{blobs.map((blob, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute rounded-full blur-[80px] opacity-40 pointer-events-none ${blob.className}`}
|
||||
style={{ background: `radial-gradient(circle, ${blob.gradient} 0%, transparent 70%)` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
export function FinalCtaSection() {
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector('input[type="email"]') as HTMLInputElement;
|
||||
input?.focus();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="join"
|
||||
className="relative py-24 px-6 overflow-hidden"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #1a2744 0%, #122035 50%, #0c1628 100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-0.5 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, transparent 0%, rgba(34, 211, 238, 0.3) 25%, rgba(34, 211, 238, 0.6) 50%, rgba(34, 211, 238, 0.3) 75%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-0 opacity-50 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 20% 50%, rgba(34, 211, 238, 0.15) 0%, transparent 40%), radial-gradient(circle at 80% 50%, rgba(34, 211, 238, 0.1) 0%, transparent 35%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-6 text-white">
|
||||
Ready to build?
|
||||
</h2>
|
||||
<p className="text-cyan-100/70 text-lg md:text-xl mb-10 max-w-2xl mx-auto">
|
||||
Join developers waiting for early access. We'll notify you when your spot is ready.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="inline-flex items-center gap-3 px-10 py-4 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 border-none rounded-xl text-white font-semibold text-lg cursor-pointer transition-all shadow-[0_8px_30px_rgba(99,102,241,0.35)] hover:shadow-[0_14px_40px_rgba(99,102,241,0.45)] hover:-translate-y-[3px]"
|
||||
>
|
||||
Get Early Access
|
||||
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-[5px]" />
|
||||
</button>
|
||||
|
||||
<p className="text-cyan-200/50 text-sm mt-8">
|
||||
No credit card required • Free to start • Cancel anytime
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import { Zap, Check, Crown, Type, Brain, Target, Image, Award } from 'lucide-react';
|
||||
|
||||
const flashFeatures = [
|
||||
{ text: 'Sub-3 second', detail: 'generation time' },
|
||||
{ text: 'Multi-turn editing', detail: '— refine through conversation' },
|
||||
{ text: 'Up to 3 reference images', detail: 'for consistency' },
|
||||
{ text: '1024px', detail: 'resolution output' },
|
||||
];
|
||||
|
||||
const proFeatures = [
|
||||
{ text: 'Up to 4K', detail: 'resolution output' },
|
||||
{ text: '14 reference images', detail: 'for brand consistency' },
|
||||
{ text: 'Studio controls', detail: '— lighting, focus, color grading' },
|
||||
{ text: 'Thinking mode', detail: '— advanced reasoning for complex prompts' },
|
||||
];
|
||||
|
||||
const capabilities = [
|
||||
{
|
||||
icon: Type,
|
||||
title: 'Perfect Text Rendering',
|
||||
description:
|
||||
'Legible text in images — logos, diagrams, posters. What other models still struggle with.',
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
title: 'Native Multimodal',
|
||||
description:
|
||||
'Understands text AND images in one model. Not a text model + image model bolted together.',
|
||||
},
|
||||
{
|
||||
icon: Target,
|
||||
title: 'Precise Prompt Following',
|
||||
description:
|
||||
'What you ask is what you get. No artistic "interpretation" that ignores your instructions.',
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
title: 'Professional Realism',
|
||||
description:
|
||||
'Photorealistic output that replaces stock photos. Not fantasy art — real, usable images.',
|
||||
},
|
||||
];
|
||||
|
||||
export function GeminiSection() {
|
||||
return (
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="bg-gradient-to-b from-[rgba(120,90,20,0.35)] via-[rgba(60,45,10,0.5)] to-[rgba(30,20,5,0.6)] border border-yellow-500/30 rounded-2xl p-8 md:p-12">
|
||||
<div className="text-center mb-10">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<Zap className="w-8 h-8 text-yellow-400" />
|
||||
<h2 className="text-2xl md:text-3xl font-bold">Powered by Google Gemini</h2>
|
||||
</div>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
We chose Gemini because it's the only model family that combines native
|
||||
multimodal understanding with production-grade image generation. Two models, optimized
|
||||
for different needs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-10">
|
||||
<div className="bg-black/30 border border-cyan-500/30 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">Gemini 2.5 Flash Image</h3>
|
||||
<p className="text-cyan-400 text-sm">Nano Banana</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Optimized for speed and iteration. Perfect for rapid prototyping and high-volume
|
||||
generation.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{flashFeatures.map((feature, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<Check className="w-4 h-4 text-cyan-400 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-gray-300">
|
||||
<strong className="text-white">{feature.text}</strong> {feature.detail}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/30 border border-yellow-500/30 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
||||
<Crown className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">Gemini 3 Pro Image</h3>
|
||||
<p className="text-yellow-400 text-sm">Nano Banana Pro</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Maximum quality and creative control. For production assets and professional
|
||||
workflows.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{proFeatures.map((feature, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<Check className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-gray-300">
|
||||
<strong className="text-white">{feature.text}</strong> {feature.detail}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-yellow-500/20 pt-8">
|
||||
<h4 className="text-center font-semibold mb-6 text-gray-300">
|
||||
Why Gemini outperforms competitors
|
||||
</h4>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{capabilities.map((cap, i) => (
|
||||
<div key={i} className="text-center p-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-yellow-500/10 flex items-center justify-center mx-auto mb-3">
|
||||
<cap.icon className="w-6 h-6 text-yellow-400" />
|
||||
</div>
|
||||
<h5 className="font-medium text-sm mb-1">{cap.title}</h5>
|
||||
<p className="text-gray-500 text-xs">{cap.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm mt-8">
|
||||
<Award className="w-4 h-4 inline mr-1 text-yellow-400" />
|
||||
Gemini 2.5 Flash Image ranked #1 on LMArena for both text-to-image and image editing
|
||||
(August 2025)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function GlowEffect({ children, id }: { children: React.ReactNode; id?: string }) {
|
||||
const [isPropertyRegistered, setIsPropertyRegistered] = useState(false);
|
||||
|
||||
// Register CSS property in component body (before render)
|
||||
if (typeof window !== 'undefined' && 'CSS' in window && 'registerProperty' in CSS) {
|
||||
try {
|
||||
CSS.registerProperty({
|
||||
name: '--form-angle',
|
||||
syntax: '<angle>',
|
||||
initialValue: '0deg',
|
||||
inherits: false,
|
||||
});
|
||||
} catch (e) {
|
||||
// Property may already be registered
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger second render to add style tag
|
||||
setIsPropertyRegistered(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPropertyRegistered && (
|
||||
<style>{`
|
||||
@keyframes form-glow-rotate {
|
||||
to {
|
||||
--form-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.email-form-wrapper {
|
||||
background: linear-gradient(#0a0612, #0a0612) padding-box,
|
||||
conic-gradient(from var(--form-angle, 0deg),
|
||||
rgba(99, 102, 241, 0.5),
|
||||
rgba(139, 92, 246, 1),
|
||||
rgba(99, 102, 241, 0.5),
|
||||
rgba(236, 72, 153, 0.8),
|
||||
rgba(99, 102, 241, 0.5),
|
||||
rgba(34, 211, 238, 1),
|
||||
rgba(99, 102, 241, 0.5)
|
||||
) border-box;
|
||||
animation: form-glow-rotate 12s linear infinite;
|
||||
}
|
||||
|
||||
.email-form-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 15px;
|
||||
background: conic-gradient(from var(--form-angle, 0deg),
|
||||
rgba(99, 102, 241, 0.2),
|
||||
rgba(139, 92, 246, 0.7),
|
||||
rgba(99, 102, 241, 0.2),
|
||||
rgba(236, 72, 153, 0.5),
|
||||
rgba(99, 102, 241, 0.2),
|
||||
rgba(34, 211, 238, 0.7),
|
||||
rgba(99, 102, 241, 0.2)
|
||||
);
|
||||
filter: blur(18px);
|
||||
opacity: 0.75;
|
||||
z-index: -1;
|
||||
animation: form-glow-rotate 12s linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
|
||||
<div id={id} className="flex items-center justify-center p-4 scroll-mt-50">
|
||||
<div className="email-form-wrapper relative isolate max-w-lg w-full mx-auto p-[2px] rounded-xl">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export function HeroGlow() {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-[1200px] h-[600px] pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse at center top, rgba(99, 102, 241, 0.2) 0%, rgba(139, 92, 246, 0.1) 30%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { Zap, Globe, FlaskConical, AtSign, Link } from 'lucide-react';
|
||||
import { WaitlistEmailForm } from './WaitlistEmailForm';
|
||||
|
||||
const badges = [
|
||||
{ icon: Zap, text: 'API-First', variant: 'default' },
|
||||
{ icon: Globe, text: 'Built-in CDN', variant: 'default' },
|
||||
{ icon: FlaskConical, text: 'Web Lab', variant: 'default' },
|
||||
{ icon: AtSign, text: 'Style References', variant: 'default' },
|
||||
{ icon: Link, text: 'Prompt URLs', variant: 'cyan' },
|
||||
];
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="relative pt-12 sm:pt-16 md:pt-20 lg:pt-24 pb-12 sm:pb-16 md:pb-20 px-4 sm:px-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
{/* Beta Badge */}
|
||||
<div className="inline-flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-3 py-1 rounded-full bg-white/5 border border-white/10 text-gray-400 text-[10px] sm:text-xs mb-5 sm:mb-6">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-500 beta-dot" />
|
||||
In Active Development
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold mb-6 sm:mb-8 md:mb-10 leading-tight">
|
||||
AI Image Generation
|
||||
<br />
|
||||
<span className="bg-[linear-gradient(90deg,#818cf8_0%,#c084fc_25%,#f472b6_50%,#c084fc_75%,#818cf8_100%)] bg-[length:200%_100%] bg-clip-text text-transparent animate-gradient-shift-hero">
|
||||
Inside Your Workflow
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="text-base sm:text-lg md:text-xl text-gray-400 mb-8 sm:mb-12 md:mb-16 lg:mb-20 max-w-2xl mx-auto">
|
||||
Generate images via API, SDK, CLI, Lab, or live URLs.
|
||||
<br className="hidden sm:inline" />
|
||||
<span className="sm:hidden"> </span>
|
||||
Production-ready CDN delivery in seconds.
|
||||
</p>
|
||||
|
||||
{/* Email Form */}
|
||||
<WaitlistEmailForm />
|
||||
|
||||
{/* Fine Print */}
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-8 sm:mb-12 md:mb-16 lg:mb-20">
|
||||
Free early access. No credit card required.
|
||||
</p>
|
||||
|
||||
{/* Feature Badges */}
|
||||
<div className="flex flex-wrap gap-2 sm:gap-3 md:gap-4 justify-center">
|
||||
{badges.map((badge, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`px-3 sm:px-4 md:px-6 py-1.5 sm:py-2 md:py-2.5 rounded-full text-xs sm:text-sm flex items-center gap-1.5 sm:gap-2 md:gap-2.5 whitespace-nowrap ${
|
||||
badge.variant === 'cyan'
|
||||
? 'bg-cyan-500/10 border border-cyan-500/30 text-cyan-300'
|
||||
: 'bg-indigo-500/15 border border-indigo-500/30 text-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<badge.icon
|
||||
className={`w-3.5 sm:w-4 h-3.5 sm:h-4 ${badge.variant === 'cyan' ? 'text-cyan-400' : 'text-indigo-400'}`}
|
||||
/>
|
||||
{badge.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { Settings2, Check, Info } from 'lucide-react';
|
||||
|
||||
const steps = [
|
||||
{ number: 1, title: 'Your Prompt', subtitle: '"a cat on windowsill"' },
|
||||
{ number: 2, title: 'Smart Enhancement', subtitle: 'Style + details added' },
|
||||
{ number: 3, title: 'AI Generation', subtitle: 'Gemini creates image' },
|
||||
{ number: 4, title: 'CDN Delivery', subtitle: 'Instant global URL' },
|
||||
];
|
||||
|
||||
const controls = [
|
||||
{ text: 'Style templates', detail: '— photorealistic, illustration, minimalist, and more' },
|
||||
{ text: 'Reference images', detail: '— @aliases maintain visual consistency' },
|
||||
{ text: 'Output specs', detail: '— aspect ratio, dimensions, format' },
|
||||
];
|
||||
|
||||
export function HowItWorksSection() {
|
||||
return (
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-4">
|
||||
Your prompt. Your control. Production-ready.
|
||||
</h2>
|
||||
<p className="text-gray-400 text-center mb-16 max-w-2xl mx-auto">
|
||||
We handle the complexity so you can focus on building.
|
||||
</p>
|
||||
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="grid md:grid-cols-4 gap-4 mb-8">
|
||||
{steps.map((step) => (
|
||||
<div key={step.number} className="text-center p-4">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 shadow-[0_2px_10px_rgba(99,102,241,0.4)] flex items-center justify-center mx-auto mb-3 text-sm font-bold">
|
||||
{step.number}
|
||||
</div>
|
||||
<p className="text-sm font-medium mb-1">{step.title}</p>
|
||||
<p className="text-xs text-gray-500">{step.subtitle}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-b from-indigo-500/10 to-[rgba(30,27,75,0.4)] border border-indigo-500/20 backdrop-blur-[10px] rounded-xl p-6 mt-8">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5 text-indigo-400" />
|
||||
What you control
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-4 text-sm">
|
||||
{controls.map((control, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-gray-300">
|
||||
<strong className="text-white">{control.text}</strong> {control.detail}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm mt-6">
|
||||
<Info className="w-4 h-4 inline mr-1" />
|
||||
Enhanced prompts are visible in API response. You always see what was generated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { Server, Code, Cpu, Terminal, FlaskConical, Link2 } from 'lucide-react';
|
||||
|
||||
const tools = [
|
||||
{ icon: Server, text: 'REST API', color: 'text-cyan-400' },
|
||||
{ icon: Code, text: 'TypeScript SDK', color: 'text-blue-400' },
|
||||
{ icon: Cpu, text: 'MCP Server', color: 'text-purple-400' },
|
||||
{ icon: Terminal, text: 'CLI', color: 'text-green-400' },
|
||||
{ icon: FlaskConical, text: 'Banatie Lab', color: 'text-orange-400' },
|
||||
{ icon: Link2, text: 'Prompt URLs', color: 'text-cyan-400', highlight: true },
|
||||
];
|
||||
|
||||
export function IntegrationsSection() {
|
||||
return (
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Works with your tools</h2>
|
||||
<p className="text-gray-400 mb-12 max-w-2xl mx-auto">
|
||||
Use what fits your workflow. All methods, same capabilities.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-8">
|
||||
{tools.map((tool, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`bg-[rgba(30,27,75,0.6)] border rounded-lg px-6 py-3 flex items-center gap-2 ${
|
||||
tool.highlight ? 'border-cyan-500/30' : 'border-indigo-500/20'
|
||||
}`}
|
||||
>
|
||||
<tool.icon className={`w-5 h-5 ${tool.color}`} />
|
||||
<span>{tool.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto mt-8 p-4 bg-slate-900/60 border border-indigo-500/15 backdrop-blur-[10px] rounded-lg">
|
||||
<p className="text-sm text-gray-400">
|
||||
<strong className="text-white">Banatie Lab</strong> — Official web interface for Banatie
|
||||
API. Generate images, build flows, browse your gallery, and explore all capabilities
|
||||
with ready-to-use code snippets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm mt-6">
|
||||
Perfect for Claude Code, Cursor, and any AI-powered workflow.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { AtSign, GitBranch, Palette, Globe, SlidersHorizontal, Link } from 'lucide-react';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: AtSign,
|
||||
iconColor: 'text-pink-400',
|
||||
title: 'Reference Images',
|
||||
description:
|
||||
'Use @aliases to maintain style consistency across your project. Reference up to 3 images per generation.',
|
||||
isUnique: false,
|
||||
},
|
||||
{
|
||||
icon: GitBranch,
|
||||
iconColor: 'text-purple-400',
|
||||
title: 'Flows',
|
||||
description:
|
||||
'Chain generations, iterate on results, build image sequences with @last and @first references.',
|
||||
isUnique: false,
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
iconColor: 'text-yellow-400',
|
||||
title: '7 Style Templates',
|
||||
description:
|
||||
'Same prompt, different styles. Photorealistic, illustration, minimalist, product, comic, sticker, and more.',
|
||||
isUnique: false,
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
iconColor: 'text-green-400',
|
||||
title: 'Instant CDN Delivery',
|
||||
description:
|
||||
'Every image gets production-ready URL. No upload, no optimization, no hosting setup needed.',
|
||||
isUnique: false,
|
||||
},
|
||||
{
|
||||
icon: SlidersHorizontal,
|
||||
iconColor: 'text-blue-400',
|
||||
title: 'Output Control',
|
||||
description:
|
||||
'Control aspect ratio, dimensions, and format. From square thumbnails to ultra-wide banners.',
|
||||
isUnique: false,
|
||||
},
|
||||
{
|
||||
icon: Link,
|
||||
iconColor: 'text-cyan-400',
|
||||
title: 'Prompt URLs',
|
||||
description:
|
||||
'Generate images via URL parameters. Put prompt in img src, get real image. Built-in caching.',
|
||||
isUnique: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function KeyFeaturesSection() {
|
||||
return (
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-4">
|
||||
Built for real development workflows
|
||||
</h2>
|
||||
<p className="text-gray-400 text-center mb-16 max-w-2xl mx-auto">
|
||||
Everything you need to integrate AI images into your projects.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded-xl p-6 ${
|
||||
feature.isUnique
|
||||
? 'bg-gradient-to-br from-cyan-500/10 to-indigo-500/[0.08] border border-cyan-500/30'
|
||||
: 'bg-gradient-to-b from-indigo-500/10 to-[rgba(30,27,75,0.4)] border border-indigo-500/20 backdrop-blur-[10px]'
|
||||
}`}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 flex items-center justify-center mb-4">
|
||||
<feature.icon className={`w-6 h-6 ${feature.iconColor}`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-semibold text-lg">{feature.title}</h3>
|
||||
{feature.isUnique && (
|
||||
<span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-300 text-xs rounded">
|
||||
Unique
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import { RefreshCw, ArrowLeftRight, Package, Layers, Check } from 'lucide-react';
|
||||
|
||||
const problems = [
|
||||
{
|
||||
icon: RefreshCw,
|
||||
title: 'Placeholder hell',
|
||||
problem: '"I\'ll add images later" never happens',
|
||||
solution: 'Generate real images as you build',
|
||||
},
|
||||
{
|
||||
icon: ArrowLeftRight,
|
||||
title: 'Context switching',
|
||||
problem: 'Leave IDE, generate elsewhere, come back',
|
||||
solution: 'Stay in your workflow. API, SDK, MCP',
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: 'Asset management',
|
||||
problem: 'Download, optimize, upload, get URL',
|
||||
solution: 'Production CDN URLs instantly',
|
||||
},
|
||||
{
|
||||
icon: Layers,
|
||||
title: 'Style drift',
|
||||
problem: 'Every image looks different',
|
||||
solution: 'Reference images keep style consistent',
|
||||
},
|
||||
];
|
||||
|
||||
export function ProblemSolutionSection() {
|
||||
return (
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-4">
|
||||
Why developers choose Banatie
|
||||
</h2>
|
||||
<p className="text-gray-400 text-center mb-16 max-w-2xl mx-auto">
|
||||
Stop fighting your image workflow. Start building.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{problems.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-gradient-to-b from-[rgba(127,29,29,0.25)] to-[rgba(30,10,20,0.7)] border border-red-400/20 backdrop-blur-[10px] rounded-xl p-6 flex flex-col min-h-[240px]"
|
||||
>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 flex items-center justify-center mb-4">
|
||||
<item.icon className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2 text-red-400">{item.title}</h3>
|
||||
<p className="text-gray-500 text-sm mb-4">{item.problem}</p>
|
||||
<div className="mt-auto flex items-start gap-1 text-sm">
|
||||
<Check className="w-4 h-4 text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-white">{item.solution}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
export function PromptUrlsSection() {
|
||||
return (
|
||||
<section className="py-16 px-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-gradient-to-br from-cyan-500/10 to-indigo-500/[0.08] border border-cyan-500/30 rounded-2xl p-8">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Sparkles className="w-6 h-6 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-block px-3 py-1 bg-cyan-500/20 text-cyan-300 text-xs rounded-full mb-2">
|
||||
Unique
|
||||
</span>
|
||||
<h2 className="text-2xl font-bold mb-2">Prompt URLs — Images via HTML</h2>
|
||||
<p className="text-gray-400">
|
||||
Put a prompt in your{' '}
|
||||
<code className="text-cyan-300 bg-black/30 px-1 rounded">img src</code> and get a
|
||||
real image. No API calls. No JavaScript. Just HTML.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/50 border border-indigo-500/20 rounded-lg p-4 font-mono text-sm overflow-x-auto">
|
||||
<span className="text-gray-500"><!-- Write this --></span>
|
||||
<br />
|
||||
<span className="text-purple-400"><img</span>{' '}
|
||||
<span className="text-cyan-300">src</span>=
|
||||
<span className="text-green-400">
|
||||
"https://cdn.banatie.app/gen?p=modern office interior"
|
||||
</span>{' '}
|
||||
<span className="text-purple-400">/></span>
|
||||
<br />
|
||||
<br />
|
||||
<span className="text-gray-500">
|
||||
<!-- Get this: production-ready image, cached, CDN-delivered -->
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm mt-4">
|
||||
Perfect for placeholder images, prototypes, static sites, and AI coding agents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { MessageCircle, Vote, Users } from 'lucide-react';
|
||||
|
||||
const ZIGZAG_POINTS = [
|
||||
40, 0, 20, 10, 30, 50, 20, 15, 0, 20, 25, 50, 10, 20, 0, 15, 10, 25, 20, 50, 0, 20, 40, 20, 25,
|
||||
10, 0, 15, 0, 20, 0, 40, 10, 0,
|
||||
];
|
||||
|
||||
function generateZigzagClipPath(yValues: number[]): string {
|
||||
const lastIndex = yValues.length - 1;
|
||||
const getX = (i: number) => `${(i / lastIndex) * 100}%`;
|
||||
|
||||
const topEdge = yValues.map((y, i) => `${getX(i)} ${y}px`).join(', ');
|
||||
const bottomEdge = [...yValues]
|
||||
.map((y, i) => [getX(i), y] as const)
|
||||
.reverse()
|
||||
.map(([x, y]) => `${x} calc(100% - 50px + ${y}px)`)
|
||||
.join(', ');
|
||||
|
||||
return `polygon(${topEdge}, ${bottomEdge})`;
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.shape-future {
|
||||
clip-path: ${generateZigzagClipPath(ZIGZAG_POINTS)};
|
||||
}
|
||||
|
||||
.metal-texture {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metal-texture::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 300 300' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||
opacity: 0.5;
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.shape-future-title {
|
||||
font-family: var(--font-caveat), cursive;
|
||||
}
|
||||
`;
|
||||
|
||||
const features = [
|
||||
{ icon: MessageCircle, text: 'Direct feedback channel' },
|
||||
{ icon: Vote, text: 'Feature voting' },
|
||||
{ icon: Users, text: 'Early adopter community' },
|
||||
];
|
||||
|
||||
export function ShapeTheFutureSection() {
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: styles }} />
|
||||
<div className="relative my-[60px]">
|
||||
<section className="shape-future metal-texture bg-[#2a2a2a] relative z-[2]">
|
||||
<section className="shape-future bg-black absolute w-full h-[500px] top-[-446px] left-[2px] opacity-30 z-[2] " />
|
||||
<div className="absolute h-[200px] w-full blur-sm">
|
||||
<section className="shape-future bg-black absolute w-full h-[500px] top-[-430px] left-[2px] opacity-30 z-[2] " />
|
||||
</div>
|
||||
<div className="absolute h-[200px] bottom-[-50px] w-full blur-sm">
|
||||
<section className="shape-future bg-black absolute w-full h-[500px] bottom-[-388px] left-[2px] opacity-20 z-[2] " />
|
||||
</div>
|
||||
<section className="shape-future bg-white absolute w-full h-[500px] bottom-[-449px] left-[1px] opacity-30 z-[2] " />
|
||||
<div className="relative z-[6] pt-[100px] pb-[60px] px-10 text-center max-w-[700px] mx-auto">
|
||||
<h2 className="shape-future-title text-5xl font-semibold text-[#f5f5f5] mb-4 leading-tight">
|
||||
Shape the future of Banatie
|
||||
</h2>
|
||||
|
||||
<p className="text-[1.05rem] text-[#a0a0a0] mb-6 leading-relaxed">
|
||||
We're building this for developers like you. Early adopters get direct influence on
|
||||
our roadmap — suggest features, vote on priorities, and help us build exactly what you
|
||||
need.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-6 justify-center text-[0.95rem] text-[#888] mb-20">
|
||||
{features.map((feature, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
<feature.icon className="w-4 h-4" />
|
||||
{feature.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import GlowEffect from './GlowEffect';
|
||||
import WaitlistPopup from './WaitlistPopup';
|
||||
import { submitEmail, submitWaitlistData } from '@/lib/actions/waitlistActions';
|
||||
|
||||
const styles = `
|
||||
.hero-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-btn.enabled {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-btn.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hero-btn.disabled:hover {
|
||||
background: rgba(100, 100, 120, 0.4) !important;
|
||||
}
|
||||
|
||||
.success-checkmark-ring {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(16, 185, 129, 0.1) 100%);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success-checkmark-inner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #10b981 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
|
||||
export function WaitlistEmailForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isInvalid, setIsInvalid] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
|
||||
const isEnabled = email.length > 0 && isValidEmail(email);
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value);
|
||||
if (isInvalid) setIsInvalid(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isValidEmail(email)) {
|
||||
setIsInvalid(true);
|
||||
return;
|
||||
}
|
||||
setIsInvalid(false);
|
||||
|
||||
submitEmail(email).catch(console.error);
|
||||
setShowPopup(true);
|
||||
};
|
||||
|
||||
const handlePopupClose = () => {
|
||||
setShowPopup(false);
|
||||
setIsSubmitted(true);
|
||||
};
|
||||
|
||||
const handleWaitlistSubmit = (data: { selected: string[]; other: string }) => {
|
||||
submitWaitlistData(email, data).catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: styles }} />
|
||||
<GlowEffect id="get-access">
|
||||
{isSubmitted ? (
|
||||
<div
|
||||
className="flex items-center justify-center gap-3 px-4 py-3 rounded-[10px]"
|
||||
style={{ background: 'rgba(10, 6, 18, 0.95)' }}
|
||||
>
|
||||
<div className="success-checkmark-ring">
|
||||
<div className="success-checkmark-inner">
|
||||
<Check className="w-3 h-3 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-white font-medium">Done! You're in the list</span>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col sm:flex-row gap-2 rounded-[10px] p-1.5 sm:pl-3"
|
||||
style={{ background: 'rgba(10, 6, 18, 0.95)' }}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
placeholder="your@email.com"
|
||||
className={`flex-1 px-4 py-3 bg-transparent border-none rounded-md outline-none placeholder:text-white/40 focus:bg-white/[0.03] ${
|
||||
isInvalid ? 'text-red-400' : 'text-white'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isEnabled}
|
||||
className={`hero-btn px-6 py-3 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-md text-white font-semibold transition-all whitespace-nowrap ${
|
||||
isEnabled ? 'enabled hover:from-indigo-600 hover:to-purple-600' : 'disabled'
|
||||
}`}
|
||||
>
|
||||
Get Early Access
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</GlowEffect>
|
||||
<WaitlistPopup isOpen={showPopup} onClose={handlePopupClose} onSubmit={handleWaitlistSubmit} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
X,
|
||||
Check,
|
||||
Globe,
|
||||
ShoppingCart,
|
||||
Gamepad2,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Palette,
|
||||
PenTool,
|
||||
Blocks,
|
||||
Compass,
|
||||
} from 'lucide-react';
|
||||
|
||||
import webDevImg from '../_assets/1.jpg';
|
||||
import mobileDevImg from '../_assets/2.jpg';
|
||||
import contentImg from '../_assets/3.jpg';
|
||||
import ecommerceImg from '../_assets/4.jpg';
|
||||
import vibeCodingImg from '../_assets/5.jpg';
|
||||
import nocodeImg from '../_assets/6.jpg';
|
||||
import gameDevImg from '../_assets/7.jpg';
|
||||
import aiArtImg from '../_assets/8.jpg';
|
||||
import justBrowsingImg from '../_assets/9.jpg';
|
||||
|
||||
interface WaitlistPopupProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { selected: string[]; other: string }) => void;
|
||||
}
|
||||
|
||||
const USE_CASES = [
|
||||
{ id: 'web-dev', label: 'Web Dev', icon: Globe, image: webDevImg },
|
||||
{ id: 'ecommerce', label: 'E-commerce', icon: ShoppingCart, image: ecommerceImg },
|
||||
{ id: 'game-dev', label: 'Game Dev', icon: Gamepad2, image: gameDevImg },
|
||||
{ id: 'mobile-dev', label: 'Mobile Dev', icon: Smartphone, image: mobileDevImg },
|
||||
{ id: 'vibecoding', label: 'Vibecoding', icon: Sparkles, image: vibeCodingImg },
|
||||
{ id: 'ai-art', label: 'AI Art', icon: Palette, image: aiArtImg },
|
||||
{ id: 'content', label: 'Content', icon: PenTool, image: contentImg },
|
||||
{ id: 'nocode', label: 'Nocode', icon: Blocks, image: nocodeImg },
|
||||
{ id: 'just-browsing', label: 'Just came across', icon: Compass, image: justBrowsingImg, dashed: true },
|
||||
];
|
||||
|
||||
export default function WaitlistPopup({ isOpen, onClose, onSubmit }: WaitlistPopupProps) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [other, setOther] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({ selected, other });
|
||||
setSelected([]);
|
||||
setOther('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.waitlist-popup-backdrop {
|
||||
background: rgba(3, 7, 18, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.waitlist-popup {
|
||||
background: linear-gradient(180deg, rgba(15, 15, 30, 0.95) 0%, rgba(10, 10, 20, 0.98) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 100px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.waitlist-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.waitlist-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to right, rgba(10, 10, 20, 0.95) 0%, rgba(10, 10, 20, 0.7) 40%, transparent 70%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waitlist-card-image {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 30%;
|
||||
mask-image: linear-gradient(to right, transparent 0%, black 40%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 40%);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.waitlist-card:hover .waitlist-card-image {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.waitlist-card.selected .waitlist-card-image {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.waitlist-card.selected:hover .waitlist-card-image {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.waitlist-card:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.waitlist-card.selected {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: rgba(168, 85, 247, 0.9);
|
||||
}
|
||||
|
||||
.waitlist-card.selected .waitlist-card-icon {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.waitlist-card-dashed {
|
||||
background: transparent;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.waitlist-card-dashed:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.waitlist-card-dashed.selected {
|
||||
background: rgba(255, 247, 237, 0.05);
|
||||
border-color: rgba(255, 236, 210, 0.8);
|
||||
}
|
||||
|
||||
.waitlist-card-dashed.selected .waitlist-card-icon {
|
||||
color: rgba(255, 236, 210, 0.9);
|
||||
}
|
||||
|
||||
.waitlist-card-dashed:hover span,
|
||||
.waitlist-card-dashed.selected span {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
|
||||
.waitlist-card-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.waitlist-card:hover .waitlist-card-icon {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.waitlist-card.selected:hover .waitlist-card-icon {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.waitlist-card-dashed.selected:hover .waitlist-card-icon {
|
||||
color: rgba(255, 236, 210, 0.9);
|
||||
}
|
||||
|
||||
.waitlist-other-label {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.waitlist-other-container:focus-within .waitlist-other-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.waitlist-other-container.has-value .waitlist-other-label {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.waitlist-other-input {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.waitlist-other-input:focus {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.waitlist-other-container.has-value .waitlist-other-input {
|
||||
border-color: rgba(168, 85, 247, 0.9);
|
||||
}
|
||||
|
||||
.waitlist-submit-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(139, 92, 246, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.waitlist-submit-btn:hover {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
border-color: rgba(139, 92, 246, 0.8);
|
||||
}
|
||||
|
||||
.waitlist-close-btn {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.waitlist-close-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.waitlist-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.1) 50%, transparent 100%);
|
||||
}
|
||||
|
||||
.waitlist-checkmark-ring {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(16, 185, 129, 0.1) 100%);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waitlist-checkmark-inner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #10b981 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="waitlist-popup-backdrop fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
{/* Popup - wide horizontal layout */}
|
||||
<div className="waitlist-popup rounded-2xl p-6 sm:p-8 w-full max-w-[900px] max-h-[90vh] overflow-y-auto relative">
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="waitlist-close-btn absolute top-4 right-4 sm:top-5 sm:right-5 p-1"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Header row: checkmark + title inline */}
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<div className="waitlist-checkmark-ring shrink-0">
|
||||
<div className="waitlist-checkmark-inner">
|
||||
<Check className="w-3.5 h-3.5 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-white text-xl font-semibold">Sweet! You'll hear from us soon</h2>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="waitlist-divider mb-5" />
|
||||
|
||||
{/* Question */}
|
||||
<p className="text-white/70 text-center mb-5 text-base font-medium">
|
||||
Quick one: what brings you here?
|
||||
</p>
|
||||
|
||||
{/* Cards Grid - 3 rows of 3, stretched horizontally */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-4">
|
||||
{USE_CASES.map(({ id, label, icon: Icon, image, dashed }) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`
|
||||
waitlist-card rounded-xl py-6 px-5 flex items-center gap-3
|
||||
${dashed ? 'waitlist-card-dashed' : ''}
|
||||
${selected.includes(id) ? 'selected' : ''}
|
||||
`}
|
||||
onClick={() => toggleSelection(id)}
|
||||
>
|
||||
<Image
|
||||
src={image}
|
||||
alt={label}
|
||||
fill
|
||||
className="waitlist-card-image object-cover"
|
||||
/>
|
||||
<Icon className="waitlist-card-icon w-5 h-5 shrink-0 relative z-10" />
|
||||
<span
|
||||
className={`text-sm font-medium relative z-10 ${dashed ? 'text-white/50' : 'text-white/80'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Other input */}
|
||||
<div className="mb-6">
|
||||
<div className={`waitlist-other-container flex items-center gap-3 ${other ? 'has-value' : ''}`}>
|
||||
<span className="waitlist-other-label text-white/40 text-sm whitespace-nowrap">Something cooler:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={other}
|
||||
onChange={(e) => setOther(e.target.value)}
|
||||
className="waitlist-other-input flex-1 rounded-lg px-4 py-2.5 text-white text-sm placeholder:text-white/20"
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
className="waitlist-submit-btn w-full py-3 rounded-xl text-white font-semibold text-[15px]"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
export { BackgroundBlobs } from './BackgroundBlobs';
|
||||
export { HeroGlow } from './HeroGlow';
|
||||
export { HeroSection } from './HeroSection';
|
||||
export { ApiExampleSection } from './ApiExampleSection';
|
||||
export { ProblemSolutionSection } from './ProblemSolutionSection';
|
||||
export { PromptUrlsSection } from './PromptUrlsSection';
|
||||
export { HowItWorksSection } from './HowItWorksSection';
|
||||
export { KeyFeaturesSection } from './KeyFeaturesSection';
|
||||
export { IntegrationsSection } from './IntegrationsSection';
|
||||
export { ShapeTheFutureSection } from './ShapeTheFutureSection';
|
||||
export { GeminiSection } from './GeminiSection';
|
||||
export { FinalCtaSection } from './FinalCtaSection';
|
||||
|
|
@ -1,445 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Refined Technical Blog Article</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#8b5cf6",
|
||||
"background-light": "#ffffff",
|
||||
"background-dark": "#0B0F19",
|
||||
"surface-light": "#f3f4f6",
|
||||
"surface-dark": "#111827",
|
||||
"border-light": "#e5e7eb",
|
||||
"border-dark": "#1f2937",
|
||||
"text-main-light": "#111827",
|
||||
"text-main-dark": "#f9fafb",
|
||||
"text-muted-light": "#6b7280",
|
||||
"text-muted-dark": "#9ca3af",
|
||||
// New colors for the dark sidebar components
|
||||
"card-dark": "#13141f",
|
||||
"card-border": "#2d2e3e",
|
||||
},
|
||||
fontFamily: {
|
||||
display: ["Inter", "sans-serif"],
|
||||
body: ["Inter", "sans-serif"],
|
||||
},
|
||||
borderRadius: {
|
||||
DEFAULT: "0.5rem",
|
||||
},
|
||||
typography: (theme) => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
color: theme('colors.text-main-light'),
|
||||
a: {
|
||||
color: theme('colors.primary'),
|
||||
'&:hover': {
|
||||
color: '#7c3aed',
|
||||
},
|
||||
},
|
||||
h1: { color: theme('colors.text-main-light') },
|
||||
h2: { color: theme('colors.text-main-light') },
|
||||
h3: { color: theme('colors.text-main-light') },
|
||||
h4: { color: theme('colors.text-main-light') },
|
||||
strong: { color: theme('colors.text-main-light') },
|
||||
code: { color: theme('colors.primary') },
|
||||
blockquote: {
|
||||
borderLeftColor: theme('colors.primary'),
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
css: {
|
||||
color: theme('colors.text-main-dark'),
|
||||
a: {
|
||||
color: '#a78bfa',
|
||||
'&:hover': {
|
||||
color: '#c4b5fd',
|
||||
},
|
||||
},
|
||||
h1: { color: theme('colors.text-main-dark') },
|
||||
h2: { color: theme('colors.text-main-dark') },
|
||||
h3: { color: theme('colors.text-main-dark') },
|
||||
h4: { color: theme('colors.text-main-dark') },
|
||||
strong: { color: theme('colors.text-main-dark') },
|
||||
code: { color: theme('colors.primary') },
|
||||
blockquote: { borderLeftColor: theme('colors.primary') },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background-light text-text-main-light antialiased transition-colors duration-200">
|
||||
<nav class="sticky top-0 z-50 w-full border-b border-white/10 bg-[#0B0F19] text-white">
|
||||
<div class="container mx-auto flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 font-bold text-xl tracking-tight">
|
||||
<div class="h-8 w-8 rounded-lg bg-gradient-to-br from-primary to-pink-500 flex items-center justify-center">
|
||||
<span class="material-icons text-white text-lg">auto_awesome</span>
|
||||
</div>
|
||||
<span>Banatie</span>
|
||||
</div>
|
||||
<div class="hidden md:flex ml-10 space-x-6 text-sm font-medium text-gray-300">
|
||||
<a class="hover:text-white transition-colors" href="#">Product</a>
|
||||
<a class="hover:text-white transition-colors" href="#">Solutions</a>
|
||||
<a class="text-white" href="#">Blog</a>
|
||||
<a class="hover:text-white transition-colors" href="#">Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<a class="hidden sm:inline-flex items-center justify-center rounded-lg bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background-dark" href="#">
|
||||
Log in
|
||||
</a>
|
||||
<a class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-violet-600 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background-dark" href="#">
|
||||
Get Access
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<header class="relative overflow-hidden bg-background-dark text-white pt-12 pb-16 lg:pt-20 lg:pb-24">
|
||||
<div class="absolute inset-0 z-0">
|
||||
<div class="absolute -top-24 -left-24 w-96 h-96 bg-primary/20 rounded-full blur-3xl"></div>
|
||||
<div class="absolute top-1/2 right-0 w-[500px] h-[500px] bg-pink-600/10 rounded-full blur-3xl transform translate-x-1/3 -translate-y-1/2"></div>
|
||||
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1635070041078-e363dbe005cb?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center opacity-10 mix-blend-overlay pointer-events-none"></div>
|
||||
</div>
|
||||
<div class="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="lg:grid lg:grid-cols-12 lg:gap-16 items-center">
|
||||
<div class="lg:col-span-7 mb-12 lg:mb-0">
|
||||
<nav aria-label="Breadcrumb" class="flex mb-8 text-xs text-gray-400 font-medium">
|
||||
<ol class="inline-flex items-center space-x-1 md:space-x-2">
|
||||
<li class="inline-flex items-center">
|
||||
<a class="hover:text-white transition-colors" href="#">Blog</a>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<span class="material-icons text-[14px] text-gray-600 mx-1">chevron_right</span>
|
||||
<a class="hover:text-white transition-colors" href="#">Engineering</a>
|
||||
</div>
|
||||
</li>
|
||||
<li aria-current="page">
|
||||
<div class="flex items-center">
|
||||
<span class="material-icons text-[14px] text-gray-600 mx-1">chevron_right</span>
|
||||
<span class="text-gray-200">Optimizing Image Generation Pipelines</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="flex items-center gap-2 mb-8">
|
||||
<span class="inline-flex items-center rounded-full bg-primary/20 px-3 py-1 text-xs font-medium text-primary ring-1 ring-inset ring-primary/30">
|
||||
Engineering
|
||||
</span>
|
||||
<span class="text-gray-400 text-sm flex items-center gap-1 ml-2">
|
||||
<span class="material-icons text-[16px]">schedule</span> 8 min read
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-extrabold tracking-tight mb-8 leading-tight text-white">
|
||||
Optimizing Image Generation Pipelines at Scale
|
||||
</h1>
|
||||
<p class="text-lg sm:text-xl text-gray-300 mb-10 max-w-2xl leading-relaxed">
|
||||
Learn how we reduced latency by 40% using edge caching and predictive pre-generation strategies for our high-throughput API endpoints.
|
||||
</p>
|
||||
<div class="flex items-center gap-4 border-t border-white/10 pt-8">
|
||||
<img alt="Author Avatar" class="h-12 w-12 rounded-full ring-2 ring-background-dark object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuB7lcPRCoZjsFsjDPuss98IvtV47CsxB3edKZH1Jy8D7TtC52cTc1lpxd6PZcqHk3lZWGFU5P-8tUB4xVMImKueltROJN-34JuWGPTdU-hEY8Z2r3ooKCANBoeB4QkCv3iZwpjpuwQlz_LJuMRCdiSJwmAfIv839cg90Lw50ekECfdKsH_zdM8g4Ig3oDsHB8sxcdoNbgZXLGdJ5K-P2QhA8FhKI9RBmvtGCLndihNZdRw405BTYJBYoQORCZ0qMfCmggjeD8Nbx2g"/>
|
||||
<div>
|
||||
<div class="font-medium text-white text-base">Alex Chen</div>
|
||||
<div class="text-sm text-gray-400">Senior Infrastructure Engineer • Oct 24, 2023</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-5 relative">
|
||||
<div class="relative rounded-xl overflow-hidden shadow-2xl ring-1 ring-white/10 bg-black/40 backdrop-blur-sm">
|
||||
<img alt="Abstract technical graphic showing network nodes" class="w-full h-auto object-cover aspect-[4/3] mix-blend-lighten opacity-90" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBQduWhcJrwC_QkSUkZ4bCXD5uh4Co2BxXWYMoN8DgTfrdRDQhMNRXYyPA-aEIkLj61sxdw64W-HhLZU8RGNh_YZ5AV2mZDgI5LArVucyhwJdotgRIDJ-oZDZYXHpD25WfsQiZVYKyDlDKBja610LlPzPJmWKOII3MbybkXab1D9xr93TEJ-AoDxFc7j2Bc_ylOKyqVfTLshdwDQDJNAVbnA-H6AavvVbnMyBUdMnFEnW-lVXROEE0mxhvwTyBqEjf68BMoqrr8sGo"/>
|
||||
<div class="absolute bottom-6 left-6 z-20 bg-gray-900/90 backdrop-blur border border-white/10 rounded-lg p-3 shadow-xl max-w-[280px]">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-red-500"></div>
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-yellow-500"></div>
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<div class="font-mono text-xs text-green-400">
|
||||
$ latency --check<br/>
|
||||
> 45ms (optimized)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="bg-background-light border-t-0 -mt-1 pt-12 lg:pt-16 pb-12">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="lg:grid lg:grid-cols-12 lg:gap-12">
|
||||
<aside class="hidden lg:block lg:col-span-1">
|
||||
<div class="sticky top-28 flex flex-col gap-4 items-center">
|
||||
<button aria-label="Share on Twitter" class="p-2 rounded-full bg-white text-gray-500 hover:text-primary transition-colors border border-gray-200 shadow-sm group">
|
||||
<svg class="w-5 h-5 fill-current" viewBox="0 0 24 24"><path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"></path></svg>
|
||||
</button>
|
||||
<button aria-label="Share on LinkedIn" class="p-2 rounded-full bg-white text-gray-500 hover:text-blue-600 transition-colors border border-gray-200 shadow-sm">
|
||||
<svg class="w-5 h-5 fill-current" viewBox="0 0 24 24"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"></path></svg>
|
||||
</button>
|
||||
<button aria-label="Copy Link" class="p-2 rounded-full bg-white text-gray-500 hover:text-gray-900 transition-colors border border-gray-200 shadow-sm">
|
||||
<span class="material-icons text-[20px]">link</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="lg:col-span-8">
|
||||
<article class="prose prose-lg prose-slate max-w-none prose-headings:font-bold prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-xl">
|
||||
<p class="lead text-xl text-gray-600 mb-8 font-light">
|
||||
When we first launched Banatie's image generation API, we optimized for quality. But as our user base grew, so did the demand for speed. Here is how we tackled the challenge of delivering AI-generated assets in milliseconds.
|
||||
</p>
|
||||
<h2>The Latency Bottleneck</h2>
|
||||
<p>
|
||||
Our initial architecture was straightforward: a request hits our API gateway, gets queued, processed by a GPU worker, and the resulting image is uploaded to S3. Simple, but slow.
|
||||
</p>
|
||||
<p>
|
||||
Users integrating our API into real-time applications needed <a href="#">faster response times</a>. We identified two main areas for improvement:
|
||||
</p>
|
||||
<ul class="marker:text-primary">
|
||||
<li><strong>Cold Starts:</strong> Spinning up new GPU instances took 2-3 minutes.</li>
|
||||
<li><strong>Network Overhead:</strong> Round trips between the inference server and storage added 200ms+.</li>
|
||||
</ul>
|
||||
<div class="my-8 rounded-lg border-l-4 border-blue-500 bg-blue-50 p-6 shadow-sm">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<span class="material-icons text-blue-600">info</span>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-gray-900 mt-0 mb-2">Pro Tip: Analyze your P99</h5>
|
||||
<p class="text-sm text-gray-700 m-0 leading-relaxed">
|
||||
Don't just look at average latency. Your P99 (99th percentile) latency tells you the experience of your users during worst-case scenarios. Optimizing for P99 often yields the most stable system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Implementing Edge Caching</h2>
|
||||
<p>
|
||||
To solve the network overhead, we moved our delivery layer to the edge. By utilizing a global CDN, we could serve cached results instantly for repeated prompts.
|
||||
</p>
|
||||
<div class="my-8 overflow-hidden rounded-xl border border-gray-200 bg-[#1e1e1e] shadow-xl">
|
||||
<div class="flex items-center justify-between border-b border-white/5 bg-[#252526] px-4 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full bg-[#ff5f56]"></div>
|
||||
<div class="h-3 w-3 rounded-full bg-[#ffbd2e]"></div>
|
||||
<div class="h-3 w-3 rounded-full bg-[#27c93f]"></div>
|
||||
</div>
|
||||
<span class="ml-4 text-xs font-mono text-gray-400">middleware/cache-control.ts</span>
|
||||
<div class="flex-grow"></div>
|
||||
<button class="text-xs text-gray-400 hover:text-white transition-colors">Copy</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-x-auto custom-scrollbar">
|
||||
<pre class="font-mono text-sm leading-relaxed text-[#d4d4d4] m-0 p-0 bg-transparent"><code><span class="text-[#c586c0]">export function</span> <span class="text-[#dcdcaa]">setCacheHeaders</span>(res: Response) {
|
||||
<span class="text-[#6a9955]">// Cache for 1 hour at the edge, validate stale in background</span>
|
||||
res.<span class="text-[#dcdcaa]">setHeader</span>(
|
||||
<span class="text-[#ce9178]'Cache-Control'</span>, <span class=" s-maxage="3600," stale-while-revalidate="600'</span" text-[#ce9178]'public,="">
|
||||
);
|
||||
<span class="text-[#6a9955]">// Custom tag for purging</span>
|
||||
res.<span class="text-[#dcdcaa]">setHeader</span>(<span class="text-[#ce9178]'Surrogate-Key'</span>, <span class=" span="" text-[#ce9178]'gen-api-v1'<="">);
|
||||
}</span></span></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<h3>The Results</h3>
|
||||
<p>
|
||||
After deploying these changes, we saw a dramatic drop in TTFB (Time To First Byte).
|
||||
</p>
|
||||
<blockquote class="my-10 border-l-4 border-primary bg-gray-50 p-6 text-xl italic font-medium leading-relaxed text-gray-800 shadow-sm rounded-r-lg">
|
||||
"The latency improvements were immediate. Our dashboard loads felt instantaneous compared to the previous version, directly impacting our user retention metrics."
|
||||
</blockquote>
|
||||
<figure class="my-10 group">
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 shadow-lg transition-all duration-300 hover:shadow-xl">
|
||||
<img alt="Graph comparing latency before and after optimization" class="w-full h-auto object-cover transform transition-transform duration-500 group-hover:scale-[1.02]" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBEgg1FA9f6Km5tYQk_92Az_mAXuc6G9ps8KamUSB_VMXwrhcFJLCpgJe7doa6ZdFLQkzJhAcT2OB_E69yQLWyKEPm7Oni0f9YV2_XjH5-jgfAMsv95vBD5r-o35be_5UmmD8-lY40hslbOB075pmwCZ56ISj5VKQARpU5s1zi1nBQvsXWK-5QywJOLp0X8VDhYlB-igMlqCGLhZh5AJ4ufr9hamWVmBiCBa__p7S_hKHjpMxbxs0Qhow_bFjM2vb2eAiUtx3wQjGI"/>
|
||||
</div>
|
||||
<figcaption class="mt-4 flex items-center justify-center gap-2 text-sm text-gray-500">
|
||||
<span class="material-icons text-[16px]">insert_chart</span>
|
||||
<span>Latency reduction over a 24-hour period post-deployment</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<h2>Predictive Pre-Generation</h2>
|
||||
<p>
|
||||
For our enterprise clients, we introduced predictive generation. By analyzing usage patterns, we can pre-warm the cache with variations of commonly requested assets before the user even asks for them.
|
||||
</p>
|
||||
<p>
|
||||
This is particularly useful for e-commerce clients who update their catalogs at predictable times.
|
||||
</p>
|
||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-900">Conclusion</h3>
|
||||
<p class="text-gray-600">
|
||||
Optimization is never finished. We are currently exploring WebAssembly for client-side resizing to further offload our servers. Stay tuned for Part 2!
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<aside class="hidden lg:block lg:col-span-3">
|
||||
<div class="sticky top-28 space-y-8">
|
||||
<div class="rounded-xl bg-gray-50 border border-gray-200 p-5 shadow-sm">
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4 border-b border-gray-200 pb-2">
|
||||
On This Page
|
||||
</h4>
|
||||
<nav class="flex flex-col space-y-3 text-sm">
|
||||
<a class="text-gray-900 font-medium pl-2 border-l-2 border-primary transition-colors hover:text-primary" href="#">The Latency Bottleneck</a>
|
||||
<a class="text-gray-500 hover:text-gray-900 pl-2 border-l-2 border-transparent transition-colors" href="#">Implementing Edge Caching</a>
|
||||
<a class="text-gray-500 hover:text-gray-900 pl-2 border-l-2 border-transparent transition-colors" href="#">The Results</a>
|
||||
<a class="text-gray-500 hover:text-gray-900 pl-2 border-l-2 border-transparent transition-colors" href="#">Predictive Pre-Generation</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Related Docs
|
||||
</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<a class="flex items-center gap-2 text-gray-600 hover:text-primary transition-colors group" href="#">
|
||||
<span class="material-icons text-[18px] text-gray-400 group-hover:text-primary">description</span>
|
||||
API Caching Policy
|
||||
</a>
|
||||
<a class="flex items-center gap-2 text-gray-600 hover:text-primary transition-colors group" href="#">
|
||||
<span class="material-icons text-[18px] text-gray-400 group-hover:text-primary">terminal</span>
|
||||
CLI Reference
|
||||
</a>
|
||||
<a class="flex items-center gap-2 text-gray-600 hover:text-primary transition-colors group" href="#">
|
||||
<span class="material-icons text-[18px] text-gray-400 group-hover:text-primary">webhook</span>
|
||||
Webhooks Guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-700 bg-slate-800 p-6 shadow-xl relative overflow-hidden group">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"></div>
|
||||
<div class="relative z-10">
|
||||
<h4 class="font-bold text-white text-lg mb-2">Build faster with Banatie</h4>
|
||||
<p class="text-sm text-gray-400 mb-6 leading-relaxed">
|
||||
Integrate AI image generation into your app in minutes. Start for free.
|
||||
</p>
|
||||
<a class="block w-full rounded-lg bg-primary px-4 py-2.5 text-center text-sm font-semibold text-white shadow-lg hover:bg-violet-600 transition-all transform hover:-translate-y-0.5" href="#">
|
||||
Get API Key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Related Articles
|
||||
</h4>
|
||||
<div class="space-y-6">
|
||||
<a class="group block rounded-xl border border-gray-200 overflow-hidden bg-white hover:border-primary/50 transition-colors shadow-sm" href="#">
|
||||
<div class="aspect-video w-full bg-gray-100 relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-pink-500/10 to-primary/10 group-hover:scale-105 transition-transform duration-500"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-primary/40">
|
||||
<span class="material-icons text-4xl">auto_graph</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h5 class="text-base font-semibold text-gray-900 group-hover:text-primary transition-colors leading-tight mb-2">
|
||||
Understanding Diffusion Models
|
||||
</h5>
|
||||
<p class="text-xs text-gray-500">
|
||||
Oct 12 • 5 min read
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a class="group block rounded-xl border border-gray-200 overflow-hidden bg-white hover:border-primary/50 transition-colors shadow-sm" href="#">
|
||||
<div class="aspect-video w-full bg-gray-100 relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-cyan-500/10 group-hover:scale-105 transition-transform duration-500"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-blue-500/40">
|
||||
<span class="material-icons text-4xl">speed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h5 class="text-base font-semibold text-gray-900 group-hover:text-primary transition-colors leading-tight mb-2">
|
||||
Managing API Quotas effectively
|
||||
</h5>
|
||||
<p class="text-xs text-gray-500">
|
||||
Sep 28 • 4 min read
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="bg-background-dark text-gray-400 border-t border-white/10">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-8">
|
||||
<div class="col-span-2 lg:col-span-2">
|
||||
<div class="flex items-center gap-2 font-bold text-white text-xl tracking-tight mb-4">
|
||||
<div class="h-6 w-6 rounded bg-gradient-to-br from-primary to-pink-500 flex items-center justify-center">
|
||||
<span class="material-icons text-white text-xs">auto_awesome</span>
|
||||
</div>
|
||||
<span>Banatie</span>
|
||||
</div>
|
||||
<p class="text-sm leading-6 mb-6 max-w-sm">
|
||||
Empowering developers to build the next generation of creative applications with production-ready AI infrastructure.
|
||||
</p>
|
||||
<div class="flex gap-4">
|
||||
<a class="hover:text-white transition-colors" href="#"><span class="sr-only">Twitter</span><svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"></path></svg></a>
|
||||
<a class="hover:text-white transition-colors" href="#"><span class="sr-only">GitHub</span><svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path clip-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" fill-rule="evenodd"></path></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white mb-4">Product</h3>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li><a class="hover:text-white transition-colors" href="#">Features</a></li>
|
||||
<li><a class="hover:text-white transition-colors" href="#">Pricing</a></li>
|
||||
<li><a class="hover:text-white transition-colors" href="#">API Reference</a></li>
|
||||
<li><a class="hover:text-white transition-colors" href="#">Integrations</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white mb-4">Resources</h3>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li><a class="hover:text-white transition-colors" href="#">Documentation</a></li>
|
||||
<li><a class="hover:text-white transition-colors" href="#">Guides</a></li>
|
||||
<li><a class="text-white font-medium" href="#">Blog</a></li>
|
||||
<li><a class="hover:text-white transition-colors" href="#">Community</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white mb-4">Company</h3>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li><a class="hover:text-white transition-colors" href="#">About</a></li>
|
||||
<li><a class="hover:text-white transition-colors" href="#">Careers</a></li>
|
||||
<li><a class="hover:text-white transition-colors" href="#">Legal</a></li>
|
||||
<li><a class="hover:text-white transition-colors" href="#">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-12 pt-8 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-4 text-xs">
|
||||
<p>© 2023 Banatie Inc. All rights reserved.</p>
|
||||
<div class="flex gap-6">
|
||||
<a class="hover:text-white" href="#">Privacy Policy</a>
|
||||
<a class="hover:text-white" href="#">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body></html>
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import {
|
||||
getAllPosts,
|
||||
getPostBySlug,
|
||||
getPostsBySlugs,
|
||||
generatePostMetadata,
|
||||
} from '../utils';
|
||||
import {
|
||||
BlogPostHeader,
|
||||
BlogTOC,
|
||||
BlogSidebar,
|
||||
BlogShareButtons,
|
||||
} from '../_components';
|
||||
import type { BlogPost } from '../types';
|
||||
|
||||
const generateJsonLd = (post: BlogPost) => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: post.title,
|
||||
description: post.description,
|
||||
image: `https://banatie.app${post.heroImage}`,
|
||||
datePublished: post.date,
|
||||
dateModified: post.date,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: post.author.name,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'Banatie',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://banatie.app/banatie-logo.png',
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `https://banatie.app/blog/${post.slug}/`,
|
||||
},
|
||||
});
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = getAllPosts();
|
||||
return posts.map((post) => ({ slug: post.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySlug(slug);
|
||||
if (!post) return {};
|
||||
return generatePostMetadata(post);
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { Content, tocItems } = await import(`../_posts/${slug}`);
|
||||
const relatedArticles = getPostsBySlugs(post.relatedArticles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(generateJsonLd(post)) }}
|
||||
/>
|
||||
<main id="main-content">
|
||||
<BlogPostHeader post={post} />
|
||||
|
||||
<div className="bg-white border-t-0 -mt-1 pt-12 lg:pt-16 pb-12">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="lg:grid lg:grid-cols-12 lg:gap-12">
|
||||
{/* Share buttons column - hidden on mobile */}
|
||||
<aside className="hidden lg:block lg:col-span-1">
|
||||
<BlogShareButtons url={`/blog/${post.slug}`} title={post.title} />
|
||||
</aside>
|
||||
|
||||
{/* Article content */}
|
||||
<div className="lg:col-span-8">
|
||||
<article className="max-w-none text-gray-700 leading-relaxed [&>p]:mb-4 [&>ul]:mb-4 [&>ul]:list-disc [&>ul]:pl-6 [&>ul_li]:mb-2 [&>ul]:marker:text-violet-500 [&>ol]:mb-4 [&>ol]:list-decimal [&>ol]:pl-6 [&>a]:text-violet-500 [&>a]:hover:underline [&_strong]:text-gray-900 [&_strong]:font-semibold">
|
||||
<Content />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - hidden on mobile */}
|
||||
<aside className="hidden lg:block lg:col-span-3">
|
||||
<div className="sticky top-28 space-y-8">
|
||||
<BlogTOC items={tocItems} />
|
||||
<BlogSidebar
|
||||
relatedArticles={relatedArticles}
|
||||
relatedDocs={post.relatedDocs}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { BlogPost } from '../types';
|
||||
|
||||
interface BlogArticleCardProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
guides: 'bg-violet-500/10',
|
||||
tutorials: 'bg-blue-500/10',
|
||||
'use-cases': 'bg-pink-500/10',
|
||||
news: 'bg-emerald-500/10',
|
||||
};
|
||||
|
||||
const formatShortDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
export const BlogArticleCard = ({ post }: BlogArticleCardProps) => {
|
||||
const overlayColor = categoryColors[post.category] || 'bg-violet-500/10';
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group flex flex-col bg-[#111827] rounded-xl overflow-hidden border border-white/5 hover:border-violet-500/50 transition-all hover:shadow-lg hover:shadow-violet-500/5 h-full"
|
||||
>
|
||||
<div className="aspect-video w-full relative overflow-hidden bg-gray-900">
|
||||
<div
|
||||
className={`absolute inset-0 ${overlayColor} group-hover:bg-transparent transition-colors z-10`}
|
||||
/>
|
||||
<Image
|
||||
src={post.heroImage}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105 opacity-80 group-hover:opacity-100"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
|
||||
/>
|
||||
<div className="absolute top-3 left-3 z-20">
|
||||
<span className="inline-flex items-center rounded-md bg-black/60 backdrop-blur-md px-2.5 py-1 text-xs font-medium text-white ring-1 ring-inset ring-white/10 capitalize">
|
||||
{post.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col flex-1">
|
||||
<h3 className="text-lg font-bold text-white mb-3 line-clamp-2 group-hover:text-violet-400 transition-colors leading-snug">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
|
||||
{post.description}
|
||||
</p>
|
||||
<div className="mt-auto flex items-center text-xs text-gray-500 font-medium">
|
||||
<span>{formatShortDate(post.date)}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{post.readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export const BlogBackground = () => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -top-[20%] -right-[10%] w-[800px] h-[800px] bg-violet-500/5 rounded-full blur-[120px]" />
|
||||
<div className="absolute top-[10%] left-0 w-[500px] h-[500px] bg-blue-600/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||