Compare commits
78 Commits
feature/do
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
c8d6214322 | |
|
|
988c7946b9 | |
|
|
b2cab168bc | |
|
|
0c656d21a2 | |
|
|
4f59f775ae | |
|
|
7f37a5667b | |
|
|
d688c5890a | |
|
|
21ac410780 | |
|
|
7588817803 | |
|
|
c148c53013 | |
|
|
1e080bd87c | |
|
|
e70610e00d | |
|
|
504b1f8395 | |
|
|
df3737ed44 | |
|
|
0ca1a4576e | |
|
|
beedac385e | |
|
|
6803a23aa3 | |
|
|
8623442157 | |
|
|
88cb1f2c61 | |
|
|
fba243cfbd | |
|
|
7da1973072 | |
|
|
191a745133 | |
|
|
5e52d4ff9c | |
|
|
e3ddf1294f | |
|
|
6235736f4f | |
|
|
3cd7eb316d | |
|
|
85e68bcb31 | |
|
|
a1c47a37f0 | |
|
|
a92b1bf482 | |
|
|
fa65264410 | |
|
|
1f768d4761 | |
|
|
1ad5b483ef | |
|
|
7d87202934 | |
|
|
9b9c47e2bf | |
|
|
647f66db7a | |
|
|
ed3931a2bd | |
|
|
8d1da7364a | |
|
|
2656b208c5 | |
|
|
3e3f15cd9c | |
|
|
bbc007bccd | |
|
|
a38c2dd954 | |
|
|
213a378532 | |
|
|
d6ca79152c | |
|
|
4e7eb7b5b5 | |
|
|
874cc4fcba | |
|
|
e55c02d158 | |
|
|
ca112886e5 | |
|
|
4785d23179 | |
|
|
071736c076 | |
|
|
85395084b7 | |
|
|
2c67dad9c2 | |
|
|
c185ea3ff4 | |
|
|
df84e400f5 | |
|
|
1236dd78e2 | |
|
|
047c924193 | |
|
|
dbf82d2801 | |
|
|
e88617b430 | |
|
|
a7dc96d1a5 | |
|
|
a397de80e9 | |
|
|
349abc2071 | |
|
|
b4e5a05ae6 | |
|
|
d6a9cd6990 | |
|
|
2f8d239da0 | |
|
|
ab85b5e1fa | |
|
|
f46a8d66d3 | |
|
|
d7c230fae8 | |
|
|
b9a8ca8368 | |
|
|
d1806bfd7e | |
|
|
f1335fb4d3 | |
|
|
a4842e2cd4 | |
|
|
d5fe272460 | |
|
|
78bff3f2ed | |
|
|
7b8c8ec5e8 | |
|
|
15eb364ebd | |
|
|
daa8117ce5 | |
|
|
4caa475f30 | |
|
|
9facc1621c | |
|
|
36bab4ddaa |
|
|
@ -0,0 +1,216 @@
|
|||
# Agent Purpose
|
||||
This agent specializes in creating and editing .rest files for the REST Client VSCode extension (https://marketplace.visualstudio.com/items?itemName=humao.rest-client). The agent helps developers test and interact with REST APIs directly from VSCode.
|
||||
|
||||
# Core Capabilities
|
||||
|
||||
The agent MUST be proficient in:
|
||||
|
||||
1. **HTTP Methods**: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
|
||||
2. **Request Bodies**: JSON, form data, multipart/form-data, XML, plain text
|
||||
3. **Variables**:
|
||||
- File-level variables
|
||||
- Environment variables from .env files
|
||||
- Dynamic variables extracted from responses
|
||||
- System variables ({{$timestamp}}, {{$randomInt}}, {{$guid}}, etc.)
|
||||
4. **Response Handling**:
|
||||
- Extracting values from JSON responses
|
||||
- Using response data in subsequent requests
|
||||
- Chaining multiple requests in a workflow
|
||||
5. **Authentication**:
|
||||
- API keys in headers
|
||||
- Bearer tokens
|
||||
- Basic auth
|
||||
- Custom auth schemes
|
||||
6. **Headers**: Content-Type, Authorization, custom headers
|
||||
7. **Query Parameters**: URL-encoded parameters
|
||||
8. **Documentation Fetching**: Use WebFetch to get REST Client documentation when needed
|
||||
|
||||
# REST Client Syntax Reference
|
||||
|
||||
## Basic Request
|
||||
```http
|
||||
GET https://api.example.com/users
|
||||
```
|
||||
|
||||
## Request with Headers
|
||||
```http
|
||||
POST https://api.example.com/users
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
## Variables
|
||||
```http
|
||||
### Variables
|
||||
@baseUrl = https://api.example.com
|
||||
@apiKey = {{$dotenv API_KEY}}
|
||||
|
||||
### Request using variables
|
||||
GET {{baseUrl}}/users
|
||||
X-API-Key: {{apiKey}}
|
||||
```
|
||||
|
||||
## Dynamic Variables (Response Extraction)
|
||||
```http
|
||||
### Login to get token
|
||||
POST {{baseUrl}}/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "secret"
|
||||
}
|
||||
|
||||
###
|
||||
@authToken = {{login.response.body.token}}
|
||||
|
||||
### Use extracted token
|
||||
GET {{baseUrl}}/protected
|
||||
Authorization: Bearer {{authToken}}
|
||||
```
|
||||
|
||||
## Form Data
|
||||
```http
|
||||
POST {{baseUrl}}/upload
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||
Content-Disposition: form-data; name="file"; filename="test.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
< ./test.jpg
|
||||
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||
```
|
||||
|
||||
## Request Separation
|
||||
Use `###` to separate multiple requests in the same file.
|
||||
|
||||
# Task Workflow
|
||||
|
||||
When asked to create .rest files:
|
||||
|
||||
1. **Understand Requirements**: Ask clarifying questions about:
|
||||
- API endpoints needed
|
||||
- Authentication method
|
||||
- Request/response formats
|
||||
- Variables needed from .env
|
||||
- Workflow dependencies
|
||||
|
||||
2. **Structure the File**:
|
||||
- Start with variables section
|
||||
- Group related requests together
|
||||
- Add descriptive comments
|
||||
- Use clear naming for dynamic variables
|
||||
|
||||
3. **Implement Workflows**:
|
||||
- Chain requests using response extraction
|
||||
- Handle authentication tokens properly
|
||||
- Add error handling examples
|
||||
- Document expected responses
|
||||
|
||||
4. **Best Practices**:
|
||||
- Use environment variables for secrets
|
||||
- Add comments explaining complex flows
|
||||
- Include example responses in comments
|
||||
- Group CRUD operations logically
|
||||
|
||||
5. **Fetch Documentation**:
|
||||
- When uncertain about syntax, use WebFetch to check:
|
||||
- https://marketplace.visualstudio.com/items?itemName=humao.rest-client
|
||||
- Search for specific features when needed
|
||||
|
||||
# Example: Complete Workflow
|
||||
|
||||
```http
|
||||
### ===========================================
|
||||
### Banatie API Testing Workflow
|
||||
### ===========================================
|
||||
|
||||
### Environment Variables
|
||||
@baseUrl = http://localhost:3000
|
||||
@masterKey = {{$dotenv MASTER_KEY}}
|
||||
@projectKey = {{$dotenv PROJECT_KEY}}
|
||||
|
||||
### ===========================================
|
||||
### 1. Health Check
|
||||
### ===========================================
|
||||
GET {{baseUrl}}/health
|
||||
|
||||
### ===========================================
|
||||
### 2. Create Project Key (Master Key Required)
|
||||
### ===========================================
|
||||
POST {{baseUrl}}/api/admin/keys
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{masterKey}}
|
||||
|
||||
{
|
||||
"type": "project",
|
||||
"projectId": "test-project",
|
||||
"name": "Test Project Key"
|
||||
}
|
||||
|
||||
###
|
||||
@newProjectKey = {{$2.response.body.data.key}}
|
||||
|
||||
### ===========================================
|
||||
### 3. Generate Image
|
||||
### ===========================================
|
||||
POST {{baseUrl}}/api/v1/generations
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{newProjectKey}}
|
||||
|
||||
{
|
||||
"prompt": "A beautiful sunset over mountains",
|
||||
"aspectRatio": "16:9",
|
||||
"alias": "@test-sunset"
|
||||
}
|
||||
|
||||
###
|
||||
@generationId = {{$3.response.body.data.id}}
|
||||
@imageId = {{$3.response.body.data.outputImage.id}}
|
||||
|
||||
### ===========================================
|
||||
### 4. Get Generation Details
|
||||
### ===========================================
|
||||
GET {{baseUrl}}/api/v1/generations/{{generationId}}
|
||||
X-API-Key: {{newProjectKey}}
|
||||
|
||||
### ===========================================
|
||||
### 5. List All Generations
|
||||
### ===========================================
|
||||
GET {{baseUrl}}/api/v1/generations?limit=10&offset=0
|
||||
X-API-Key: {{newProjectKey}}
|
||||
```
|
||||
|
||||
# Agent Behavior
|
||||
|
||||
- **Proactive**: Suggest improvements to API testing workflows
|
||||
- **Thorough**: Include all necessary headers and parameters
|
||||
- **Educational**: Explain REST Client syntax when creating files
|
||||
- **Practical**: Focus on real-world API testing scenarios
|
||||
- **Current**: Fetch documentation when uncertain about features
|
||||
|
||||
# Tools Available
|
||||
|
||||
- **Read**: Read existing .rest files
|
||||
- **Write**: Create new .rest files
|
||||
- **Edit**: Modify existing .rest files
|
||||
- **Glob/Grep**: Find existing API-related files
|
||||
- **WebFetch**: Fetch REST Client documentation
|
||||
- **Bash**: Test API endpoints to verify .rest file correctness
|
||||
|
||||
# Success Criteria
|
||||
|
||||
A successful .rest file should:
|
||||
1. Execute without syntax errors
|
||||
2. Properly chain requests when needed
|
||||
3. Use variables from .env for secrets
|
||||
4. Include clear comments and structure
|
||||
5. Cover the complete API workflow
|
||||
6. Handle authentication correctly
|
||||
7. Extract and use response data appropriately
|
||||
|
|
@ -0,0 +1,934 @@
|
|||
# Banatie API v1 - Technical Changes and Refactoring
|
||||
|
||||
## Context
|
||||
|
||||
Project is in active development with no existing clients. All changes can be made without backward compatibility concerns. **Priority: high-quality and correct API implementation.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Parameter Naming Cleanup ✅
|
||||
|
||||
### 1.1 POST /api/v1/generations
|
||||
|
||||
**Current parameters:**
|
||||
- `assignAlias` → rename to `alias`
|
||||
- `assignFlowAlias` → rename to `flowAlias`
|
||||
|
||||
**Rationale:** Shorter, clearer, no need for "assign" prefix when assignment is obvious from endpoint context.
|
||||
|
||||
**Affected areas:**
|
||||
- Request type definitions
|
||||
- Route handlers
|
||||
- Service methods
|
||||
- API documentation
|
||||
|
||||
### 1.2 Reference Images Auto-Detection
|
||||
|
||||
**Parameter behavior:**
|
||||
- `referenceImages` parameter is **optional**
|
||||
- If provided (array of aliases or IDs) → use these images as references
|
||||
- If empty or not provided → service must automatically parse prompt and find all aliases
|
||||
|
||||
**Auto-detection logic:**
|
||||
|
||||
1. **Prompt parsing:**
|
||||
- Scan prompt text for all alias patterns (@name)
|
||||
- Extract all found aliases
|
||||
- Resolve each alias to actual image ID
|
||||
|
||||
2. **Manual override:**
|
||||
- If `referenceImages` parameter is provided and not empty → use only specified images
|
||||
- Manual list takes precedence over auto-detected aliases
|
||||
|
||||
3. **Combined approach:**
|
||||
- If `referenceImages` provided → add to auto-detected aliases (merge)
|
||||
- Remove duplicates
|
||||
- Maintain order: manual references first, then auto-detected
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
// Auto-detection (no referenceImages parameter)
|
||||
{
|
||||
"prompt": "A landscape based on @sunset with elements from @mountain"
|
||||
// System automatically detects @sunset and @mountain
|
||||
}
|
||||
|
||||
// Manual specification
|
||||
{
|
||||
"prompt": "A landscape",
|
||||
"referenceImages": ["@sunset", "image-uuid-123"]
|
||||
// System uses only specified images
|
||||
}
|
||||
|
||||
// Combined
|
||||
{
|
||||
"prompt": "A landscape based on @sunset",
|
||||
"referenceImages": ["@mountain"]
|
||||
// System uses both @mountain (manual) and @sunset (auto-detected)
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation notes:**
|
||||
- Alias detection must use the same validation rules as alias creation
|
||||
- Invalid aliases in prompt should be logged but not cause generation failure
|
||||
- Maximum reference images limit still applies after combining manual + auto-detected
|
||||
|
||||
---
|
||||
|
||||
## 2. Enhanced Prompt Support - Logic Redesign
|
||||
|
||||
### 2.1 Database Schema Changes
|
||||
|
||||
**Required schema modifications:**
|
||||
|
||||
1. **Rename field:** `enhancedPrompt` → `originalPrompt`
|
||||
2. **Change field semantics:**
|
||||
- `prompt` - ALWAYS contains the prompt that was used for generation (enhanced or original)
|
||||
- `originalPrompt` - ALWAYS contains user's original input (for transparency and audit trail)
|
||||
|
||||
**Field population logic:**
|
||||
|
||||
```
|
||||
Case 1: autoEnhance = false
|
||||
prompt = user input
|
||||
originalPrompt = user input (same value, preserved for consistency)
|
||||
|
||||
Case 2: autoEnhance = true
|
||||
prompt = enhanced prompt (used for generation)
|
||||
originalPrompt = user input (preserved)
|
||||
```
|
||||
|
||||
**Rationale:** Always storing `originalPrompt` provides:
|
||||
- Audit trail of user's actual input
|
||||
- Ability to compare original vs enhanced prompts
|
||||
- Consistent API response structure
|
||||
- Simplified client logic (no null checks needed)
|
||||
|
||||
### 2.2 API Response Format
|
||||
|
||||
**Response structure:**
|
||||
```json
|
||||
{
|
||||
"prompt": "detailed enhanced prompt...", // Always the prompt used for generation
|
||||
"originalPrompt": "sunset", // Always the user's original input
|
||||
"autoEnhance": true // True if prompt differs from originalPrompt
|
||||
}
|
||||
```
|
||||
|
||||
**Affected endpoints:**
|
||||
- `POST /api/v1/generations` response
|
||||
- `GET /api/v1/generations/:id` response
|
||||
- `GET /api/v1/generations` list response
|
||||
|
||||
---
|
||||
|
||||
## 3. Regeneration Endpoint Refactoring ✅
|
||||
|
||||
### 3.1 Endpoint Rename
|
||||
|
||||
**Change:**
|
||||
- ❌ OLD: `POST /api/v1/generations/:id/retry`
|
||||
- ✅ NEW: `POST /api/v1/generations/:id/regenerate`
|
||||
|
||||
### 3.2 Remove Status Checks
|
||||
|
||||
- Remove `if (original.status === 'success') throw error` check
|
||||
- Remove `GENERATION_ALREADY_SUCCEEDED` error constant
|
||||
- Allow regeneration for any status (pending, processing, success, failed)
|
||||
|
||||
### 3.3 Remove Retry Logic
|
||||
|
||||
- Remove `retryCount >= MAX_RETRY_COUNT` check
|
||||
- Remove retryCount increment
|
||||
- Remove `MAX_RETRY_COUNT` constant
|
||||
|
||||
### 3.4 Remove Override Parameters
|
||||
|
||||
- Remove `prompt` and `aspectRatio` parameters from request body
|
||||
- Always regenerate with exact same parameters as original
|
||||
|
||||
### 3.5 Image Update Behavior
|
||||
|
||||
**Update existing image instead of creating new:**
|
||||
|
||||
**Preserve:**
|
||||
- `imageId` (UUID remains the same)
|
||||
- `storageKey` (MinIO path)
|
||||
- `storageUrl`
|
||||
- `alias` (if assigned)
|
||||
- `createdAt` (original creation timestamp)
|
||||
|
||||
**Update:**
|
||||
- Physical file in MinIO (overwrite)
|
||||
- `fileSize` (if changed)
|
||||
- `updatedAt` timestamp
|
||||
|
||||
**Generation record:**
|
||||
- Update `status` → processing → success/failed
|
||||
- Update `processingTimeMs`
|
||||
- Keep `outputImageId` (same value)
|
||||
- Keep `flowId` (if present)
|
||||
|
||||
### 3.6 Additional Endpoint
|
||||
|
||||
**Add for Flow:**
|
||||
- `POST /api/v1/flows/:id/regenerate`
|
||||
- Regenerates the most recent generation in flow
|
||||
- Returns `FLOW_HAS_NO_GENERATIONS` error if flow is empty
|
||||
- Uses parameters from the last generation in flow
|
||||
|
||||
---
|
||||
|
||||
## 4. Flow Auto-Creation (Lazy Flow Pattern)
|
||||
|
||||
### 4.1 Lazy Flow Creation Strategy
|
||||
|
||||
**Concept:**
|
||||
1. **First request without flowId** → return generated `flowId` in response, but **DO NOT create in DB**
|
||||
2. **Any request with valid flowId** → create flow in DB if doesn't exist, add this request to flow
|
||||
3. **If flowAlias specified in request** → create flow immediately (eager creation)
|
||||
|
||||
### 4.2 Implementation Details
|
||||
|
||||
**Flow ID Generation:**
|
||||
- When generation/upload has no flowId, generate UUID for potential flow
|
||||
- Return this flowId in response
|
||||
- Save `flowId` in generation/image record, but DO NOT create flow record
|
||||
|
||||
**Flow Creation in DB:**
|
||||
|
||||
**Trigger:** ANY request with valid flowId value
|
||||
|
||||
**Logic:**
|
||||
1. Check if flow record exists in DB
|
||||
2. Check if there are existing generations/images with this flowId
|
||||
3. If flow doesn't exist:
|
||||
- Create flow record with provided flowId
|
||||
- Include all existing records with this flowId
|
||||
- Maintain chronological order based on createdAt timestamps
|
||||
4. If flow exists:
|
||||
- Add new record to existing flow
|
||||
|
||||
**Eager creation:**
|
||||
- If request includes `flowAlias` → create flow immediately
|
||||
- Set alias in `flow.aliases` object
|
||||
|
||||
**Database Schema:**
|
||||
- `generations` table already has `flowId` field (foreign key to flows.id)
|
||||
- `images` table already has `flowId` field (foreign key to flows.id)
|
||||
- No schema changes needed
|
||||
|
||||
**Orphan flowId handling:**
|
||||
- If `flowId` exists in generation/image record but not in `flows` table - this is normal
|
||||
- Such records are called "orphans" and simply not shown in `GET /api/v1/flows` list
|
||||
- No cleanup job needed
|
||||
- Do NOT delete such records automatically
|
||||
- System works correctly with orphan flowIds until flow record is created
|
||||
|
||||
### 4.3 Endpoint Changes
|
||||
|
||||
**Remove:**
|
||||
- ❌ `POST /api/v1/flows` endpoint (no longer needed)
|
||||
|
||||
**Modify responses:**
|
||||
- `POST /api/v1/generations` → always return `flowId` in response (see section 10.1)
|
||||
- `POST /api/v1/images/upload` → always return `flowId` in response (see section 10.1)
|
||||
|
||||
---
|
||||
|
||||
## 5. Upload Image Enhancements
|
||||
|
||||
### 5.1 Add Parameters
|
||||
|
||||
**POST /api/v1/images/upload:**
|
||||
|
||||
**Parameters:**
|
||||
- `alias` (optional, string) - project-scoped alias
|
||||
- `flowAlias` (optional, string) - flow-scoped alias for uploaded image
|
||||
- `flowId` (optional, string) - flow association
|
||||
|
||||
**Behavior:**
|
||||
- If `flowAlias` and `flowId` specified:
|
||||
- Ensure flow exists (or create via lazy pattern)
|
||||
- Add alias to `flow.aliases` object
|
||||
- If `flowAlias` WITHOUT `flowId`:
|
||||
- Apply lazy flow creation with eager pattern
|
||||
- Create flow immediately, set flowAlias
|
||||
- If only `alias` specified:
|
||||
- Set project-scoped alias on image
|
||||
|
||||
### 5.2 Alias Conflict Resolution
|
||||
|
||||
**Validation rules:**
|
||||
|
||||
1. **Technical aliases are forbidden:**
|
||||
- Cannot use: `@last`, `@first`, `@upload` or any reserved technical alias
|
||||
- Return validation error if attempted
|
||||
|
||||
2. **Alias override behavior:**
|
||||
- If alias already exists → new request has higher priority
|
||||
- Alias points to new image
|
||||
- Previous image loses its alias but is NOT deleted
|
||||
- Same logic applies to both project aliases and flow aliases
|
||||
|
||||
3. **Applies to both:**
|
||||
- Image upload with alias
|
||||
- Generation with alias/flowAlias
|
||||
|
||||
**Example:**
|
||||
```
|
||||
State: Image A has alias "@hero"
|
||||
Request: Upload Image B with alias "@hero"
|
||||
Result:
|
||||
- Image B now has alias "@hero"
|
||||
- Image A loses alias (alias = NULL)
|
||||
- Image A is NOT deleted
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Image Alias Management Refactoring
|
||||
|
||||
### 6.1 Endpoint Consolidation
|
||||
|
||||
**Remove alias handling from:**
|
||||
- ❌ `PUT /api/v1/images/:id` (body: { alias, focalPoint, meta })
|
||||
- Remove `alias` parameter
|
||||
- Keep only `focalPoint` and `meta`
|
||||
|
||||
**Single method for project-scoped alias management:**
|
||||
- ✅ `PUT /api/v1/images/:id/alias` (body: { alias })
|
||||
- Set new alias
|
||||
- Change existing alias
|
||||
- Remove alias (pass `alias: null`)
|
||||
|
||||
**Rationale:** Explicit intent, dedicated endpoint for alias operations, simpler validation.
|
||||
|
||||
### 6.2 Alias as Image Identifier
|
||||
|
||||
**Support alias in path parameters:**
|
||||
|
||||
**Syntax:**
|
||||
- UUID: `GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000`
|
||||
- Alias: `GET /api/v1/images/@hero`
|
||||
- `@` symbol distinguishes alias from UUID (UUIDs never contain `@`)
|
||||
|
||||
**UUID validation:** UUIDs can NEVER contain `@` symbol - this guarantees no conflicts
|
||||
|
||||
**Flow-scoped resolution:**
|
||||
- `GET /api/v1/images/@hero?flowId=uuid-123`
|
||||
- Searches for alias `@hero` in context of flow `uuid-123`
|
||||
- Uses 3-tier precedence (technical → flow → project)
|
||||
|
||||
**Endpoints with alias support:**
|
||||
- `GET /api/v1/images/:id_or_alias`
|
||||
- `PUT /api/v1/images/:id_or_alias` (for focalPoint, meta)
|
||||
- `PUT /api/v1/images/:id_or_alias/alias`
|
||||
- `DELETE /api/v1/images/:id_or_alias`
|
||||
|
||||
**Implementation:**
|
||||
- Check first character of path parameter
|
||||
- If starts with `@` → resolve via AliasService
|
||||
- If doesn't start with `@` → treat as UUID
|
||||
- After resolution, work with imageId as usual
|
||||
|
||||
### 6.3 CDN-style Image URLs with Alias Support
|
||||
|
||||
**Current URL format must be changed.**
|
||||
|
||||
**New standardized URL patterns:**
|
||||
|
||||
**For all generated and uploaded images:**
|
||||
```
|
||||
GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias
|
||||
```
|
||||
|
||||
**For live URLs:**
|
||||
```
|
||||
GET /cdn/:orgSlug/:projectSlug/live/:scope?prompt=...&aspectRatio=...
|
||||
```
|
||||
|
||||
**All image URLs returned by API must follow this pattern.**
|
||||
|
||||
**Resolution Logic:**
|
||||
1. Check if `:filenameOrAlias` starts with `@`
|
||||
2. If yes → resolve alias via AliasService
|
||||
3. If no → search by filename/storageKey
|
||||
4. Return image bytes with proper content-type headers
|
||||
|
||||
**Response Headers:**
|
||||
- Content-Type: image/jpeg (or appropriate MIME type)
|
||||
- Cache-Control: public, max-age=31536000
|
||||
- ETag: based on imageId or fileHash
|
||||
|
||||
**URL Encoding for prompts:**
|
||||
- Spaces can be replaced with underscores `_` for convenience
|
||||
- Both `prompt=beautiful%20sunset` and `prompt=beautiful_sunset` are valid
|
||||
- System should handle both formats
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
GET /cdn/acme/website/img/@hero → resolve @hero alias
|
||||
GET /cdn/acme/website/img/logo.png → find by filename
|
||||
GET /cdn/acme/website/img/@product-1 → resolve @product-1 alias
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
- Alias not found → 404
|
||||
- Filename not found → 404
|
||||
- Multiple matches → alias takes priority over filename
|
||||
|
||||
---
|
||||
|
||||
## 7. Deletion Strategy Overhaul
|
||||
|
||||
### 7.1 Image Deletion (Hard Delete)
|
||||
|
||||
**DELETE /api/v1/images/:id**
|
||||
|
||||
**Operations:**
|
||||
1. Delete physical file from MinIO storage
|
||||
2. Delete record from `images` table (hard delete)
|
||||
3. Cascade: set `outputImageId = NULL` in related generations
|
||||
4. Cascade: **completely remove alias entries** from all `flow.aliases` where imageId is referenced
|
||||
- Remove entire key-value pairs, not just values
|
||||
5. Cascade: remove imageId from `generation.referencedImages` JSON arrays
|
||||
|
||||
**Example cascade for flow.aliases:**
|
||||
```
|
||||
Before: flow.aliases = { "@hero": "img-123", "@product": "img-456" }
|
||||
Delete img-123
|
||||
After: flow.aliases = { "@product": "img-456" }
|
||||
```
|
||||
|
||||
**Rationale:** User wants to delete - remove completely, free storage. Alias entries are also completely removed.
|
||||
|
||||
### 7.2 Generation Deletion (Conditional)
|
||||
|
||||
**DELETE /api/v1/generations/:id**
|
||||
|
||||
**Behavior depends on output image alias:**
|
||||
|
||||
**Case 1: Output image WITHOUT project alias**
|
||||
1. Delete output image completely (hard delete with MinIO cleanup)
|
||||
2. Delete generation record (hard delete)
|
||||
|
||||
**Case 2: Output image WITH project alias**
|
||||
1. Keep output image (do not delete)
|
||||
2. Delete only generation record (hard delete)
|
||||
3. Set `generationId = NULL` in image record
|
||||
|
||||
**Decision Logic:**
|
||||
- If `outputImage.alias !== null` → keep image, delete only generation
|
||||
- If `outputImage.alias === null` → delete both image and generation
|
||||
|
||||
**Rationale:**
|
||||
- Image with project alias is used as standalone asset, preserve it
|
||||
- Image without alias was created only for this generation, delete together
|
||||
|
||||
**No regeneration of deleted generations** - deleted generations cannot be regenerated
|
||||
|
||||
### 7.3 Flow Deletion (Cascade with Alias Protection)
|
||||
|
||||
**DELETE /api/v1/flows/:id**
|
||||
|
||||
**Operations:**
|
||||
1. Delete flow record from DB
|
||||
2. Cascade: delete all generations associated with this flowId
|
||||
3. Cascade: delete all images associated with this flowId **EXCEPT** images with project alias
|
||||
|
||||
**Detailed Cascade Logic:**
|
||||
|
||||
**For Generations:**
|
||||
- Delete each generation (follows conditional delete from 7.2)
|
||||
- If output image has no alias → delete image
|
||||
- If output image has alias → keep image, set generationId = NULL, set flowId = NULL
|
||||
|
||||
**For Images (uploaded):**
|
||||
- If image has no alias → delete (with MinIO cleanup)
|
||||
- If image has alias → keep, set flowId = NULL
|
||||
|
||||
**Summary:**
|
||||
- Flow record → DELETE
|
||||
- All generations → DELETE
|
||||
- Images without alias → DELETE (with MinIO cleanup)
|
||||
- Images with project alias → KEEP (unlink: flowId = NULL)
|
||||
|
||||
**Rationale:**
|
||||
Flow deletion removes all content except images with project aliases (used globally in project).
|
||||
|
||||
### 7.4 Transactional Delete Pattern
|
||||
|
||||
**All delete operations must be transactional:**
|
||||
|
||||
1. Delete from MinIO storage first
|
||||
2. Delete from database (with cascades)
|
||||
3. If MinIO delete fails → rollback DB transaction
|
||||
4. If DB delete fails → cleanup MinIO file (or rollback if possible)
|
||||
5. Log all delete operations for audit trail
|
||||
|
||||
**Principle:** System must be designed so orphaned files in MinIO NEVER occur.
|
||||
|
||||
**Database Constraints:**
|
||||
- ON DELETE CASCADE for appropriate foreign keys
|
||||
- ON DELETE SET NULL where related records must be preserved
|
||||
- Proper referential integrity
|
||||
|
||||
**No background cleanup jobs needed** - system is self-sufficient and always consistent.
|
||||
|
||||
---
|
||||
|
||||
## 8. Live URL System
|
||||
|
||||
### 8.1 Core Concept
|
||||
|
||||
**Purpose:** Permanent URLs that can be immediately inserted into HTML and work forever.
|
||||
|
||||
**Use Case:**
|
||||
```html
|
||||
<img src="https://banatie.app/cdn/acme/website/live/hero-section?prompt=beautiful_sunset&aspectRatio=16:9"/>
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- URL is constructed immediately and used permanently
|
||||
- No preliminary generation through API needed
|
||||
- No signed URLs or tokens in query params
|
||||
- First request → generation, subsequent → cache
|
||||
|
||||
### 8.2 URL Format & Structure
|
||||
|
||||
**URL Pattern:**
|
||||
```
|
||||
/cdn/:orgSlug/:projectSlug/live/:scope?prompt=...&aspectRatio=...
|
||||
```
|
||||
|
||||
**URL Components:**
|
||||
```
|
||||
/cdn/acme/website/live/hero-section?prompt=beautiful_sunset&aspectRatio=16:9
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └─ Generation params (query string)
|
||||
│ │ │ └─ Scope identifier
|
||||
│ │ └─ "live" prefix
|
||||
│ └─ Project slug
|
||||
└─ Organization slug
|
||||
```
|
||||
|
||||
**Scope Parameter:**
|
||||
- Name: `scope` (confirmed)
|
||||
- Purpose: logical separation of live URLs within project
|
||||
- Format: alphanumeric + hyphens + underscores
|
||||
- Any user can specify any scope (no validation/signature required)
|
||||
|
||||
### 8.3 First Request Flow
|
||||
|
||||
**Cache MISS (first request):**
|
||||
1. Parse orgSlug, projectSlug, scope from URL
|
||||
2. Compute cache key: hash(projectId + scope + prompt + params)
|
||||
3. Check if image exists in cache
|
||||
4. If NOT found:
|
||||
- Check scope settings (allowNewGenerations, limit)
|
||||
- Trigger image generation
|
||||
- Create database records (generation, image, cache entry)
|
||||
- Wait for generation to complete
|
||||
- Return image bytes
|
||||
|
||||
**Response:**
|
||||
- Content-Type: image/jpeg
|
||||
- Cache-Control: public, max-age=31536000
|
||||
- X-Cache-Status: MISS
|
||||
- X-Scope: hero-section
|
||||
- X-Image-Id: uuid
|
||||
|
||||
**Cache HIT (subsequent requests):**
|
||||
1. Same cache key lookup
|
||||
2. Found existing image
|
||||
3. Return cached image bytes immediately
|
||||
|
||||
**Response:**
|
||||
- Content-Type: image/jpeg
|
||||
- Cache-Control: public, max-age=31536000
|
||||
- X-Cache-Status: HIT
|
||||
- X-Image-Id: uuid
|
||||
|
||||
**Generation in Progress:**
|
||||
- If image is not in cache but generation is already running:
|
||||
- System must have internal status to track this
|
||||
- Wait for generation to complete
|
||||
- Return image bytes immediately when ready
|
||||
- This ensures consistent behavior for concurrent requests
|
||||
|
||||
### 8.4 Scope Management
|
||||
|
||||
**Database Table: `live_scopes`**
|
||||
|
||||
Create dedicated table with fields:
|
||||
- `id` (UUID, primary key)
|
||||
- `project_id` (UUID, foreign key to projects)
|
||||
- `slug` (TEXT, unique within project) - used in URL
|
||||
- `allowNewGenerations` (BOOLEAN, default: true) - controls if new generations can be triggered
|
||||
- `newGenerationsLimit` (INTEGER, default: 30) - max number of generations in this scope
|
||||
- `created_at` (TIMESTAMP)
|
||||
- `updated_at` (TIMESTAMP)
|
||||
|
||||
**Scope Behavior:**
|
||||
|
||||
**allowNewGenerations:**
|
||||
- Controls whether new generations can be triggered in this scope
|
||||
- Already generated images are ALWAYS served publicly regardless of this setting
|
||||
- Default: true
|
||||
|
||||
**newGenerationsLimit:**
|
||||
- Limit on number of generations in this scope
|
||||
- Only affects NEW generations, does not affect regeneration
|
||||
- Default: 30
|
||||
|
||||
**Scope Creation:**
|
||||
- Manual: via dedicated endpoint (see below)
|
||||
- Automatic: when new scope is used in live URL (if project allows)
|
||||
|
||||
**Project-level Settings:**
|
||||
|
||||
Add to projects table or settings:
|
||||
- `allowNewLiveScopes` (BOOLEAN, default: true) - allows creating new scopes via live URLs
|
||||
- If false: new scopes cannot be created via live URL
|
||||
- If false: scopes can still be created via API endpoint
|
||||
- `newLiveScopesGenerationLimit` (INTEGER, default: 30) - generation limit for auto-created scopes
|
||||
- This value is set as `newGenerationsLimit` for newly created scopes
|
||||
|
||||
### 8.5 Scope Management API
|
||||
|
||||
**Create scope (manual):**
|
||||
```
|
||||
POST /api/v1/live/scopes
|
||||
Headers: X-API-Key: bnt_project_key
|
||||
Body: {
|
||||
"slug": "hero-section",
|
||||
"allowNewGenerations": true,
|
||||
"newGenerationsLimit": 50
|
||||
}
|
||||
```
|
||||
|
||||
**List scopes:**
|
||||
```
|
||||
GET /api/v1/live/scopes
|
||||
Headers: X-API-Key: bnt_project_key
|
||||
Response: {
|
||||
"scopes": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"slug": "hero-section",
|
||||
"allowNewGenerations": true,
|
||||
"newGenerationsLimit": 50,
|
||||
"currentGenerations": 23,
|
||||
"lastGeneratedAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Get scope details:**
|
||||
```
|
||||
GET /api/v1/live/scopes/:slug
|
||||
Headers: X-API-Key: bnt_project_key
|
||||
Response: {
|
||||
"id": "uuid",
|
||||
"slug": "hero-section",
|
||||
"allowNewGenerations": true,
|
||||
"newGenerationsLimit": 50,
|
||||
"currentGenerations": 23,
|
||||
"images": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Update scope:**
|
||||
```
|
||||
PUT /api/v1/live/scopes/:slug
|
||||
Headers: X-API-Key: bnt_project_key
|
||||
Body: {
|
||||
"allowNewGenerations": false,
|
||||
"newGenerationsLimit": 100
|
||||
}
|
||||
```
|
||||
|
||||
**Regenerate scope images:**
|
||||
```
|
||||
POST /api/v1/live/scopes/:slug/regenerate
|
||||
Headers: X-API-Key: bnt_project_key
|
||||
Body: { "imageId": "uuid" } // Optional: regenerate specific image
|
||||
Response: {
|
||||
"regenerated": 1,
|
||||
"images": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Delete scope:**
|
||||
```
|
||||
DELETE /api/v1/live/scopes/:slug
|
||||
Headers: X-API-Key: bnt_project_key
|
||||
```
|
||||
|
||||
**Deletion behavior:** Deletes all images in this scope (follows standard image deletion with alias protection).
|
||||
|
||||
### 8.6 Security & Rate Limiting
|
||||
|
||||
**Rate Limiting by IP:**
|
||||
- Aggressive limits for live URLs (e.g., 10 new generations per hour per IP)
|
||||
- Separate from API key limits
|
||||
- Cache hits do NOT count toward limit
|
||||
- Only new generations count
|
||||
|
||||
**Scope Quotas:**
|
||||
- Maximum N unique prompts per scope (newGenerationsLimit)
|
||||
- After limit reached → return existing images, do not generate new
|
||||
- Regeneration does not count toward limit
|
||||
|
||||
### 8.7 Caching Strategy
|
||||
|
||||
**Cache Key:**
|
||||
```
|
||||
cacheKey = hash(projectId + scope + prompt + aspectRatio + otherParams)
|
||||
```
|
||||
|
||||
**Cache Invalidation:**
|
||||
- Manual: via API endpoint regenerate
|
||||
- Automatic: never (images cached forever unless explicitly regenerated)
|
||||
|
||||
**Scope Naming:** `scope` (confirmed)
|
||||
|
||||
**URL Encoding:**
|
||||
- Prompt in query string: URL-encoded or underscores for spaces
|
||||
- Both formats supported: `prompt=beautiful%20sunset` and `prompt=beautiful_sunset`
|
||||
- Scope in path: alphanumeric + hyphens + underscores
|
||||
|
||||
### 8.8 Error Handling
|
||||
|
||||
**Detailed errors for live URLs:**
|
||||
|
||||
- Invalid scope format → 400 "Invalid scope format. Use alphanumeric characters, hyphens, and underscores"
|
||||
- New scope creation disabled → 403 "Creating new live scopes is disabled for this project"
|
||||
- Generation limit exceeded → 429 "Scope generation limit exceeded. Maximum N generations per scope"
|
||||
- Generation fails → 500 with retry logic
|
||||
- Rate limit by IP exceeded → 429 "Rate limit exceeded. Try again in X seconds" with Retry-After header
|
||||
|
||||
---
|
||||
|
||||
## 9. Generation Modification
|
||||
|
||||
### 9.1 Update Generation Endpoint
|
||||
|
||||
**New endpoint:**
|
||||
```
|
||||
PUT /api/v1/generations/:id
|
||||
```
|
||||
|
||||
**Modifiable Fields:**
|
||||
- `prompt` - change prompt
|
||||
- `aspectRatio` - change aspect ratio
|
||||
- `flowId` - change/remove/add flow association
|
||||
- `meta` - update metadata
|
||||
|
||||
**Behavior:**
|
||||
|
||||
**Case 1: Non-generative parameters (flowId, meta)**
|
||||
- Simply update fields in DB
|
||||
- Do NOT regenerate image
|
||||
|
||||
**Case 2: Generative parameters (prompt, aspectRatio)**
|
||||
- Update fields in DB
|
||||
- Automatically trigger regeneration
|
||||
- Update existing image (same imageId, path, URL)
|
||||
|
||||
### 9.2 FlowId Management
|
||||
|
||||
**FlowId handling:**
|
||||
- `flowId: null` → detach from flow
|
||||
- `flowId: "new-uuid"` → attach to different flow
|
||||
- If flow doesn't exist → create new flow eagerly (with this flowId)
|
||||
- If flow exists → add generation to existing flow
|
||||
- `flowId: undefined` → do not change current value
|
||||
|
||||
**Use Case - "Detach from Flow":**
|
||||
- Set `flowId: null` to detach generation from flow
|
||||
- Output image is preserved (if has alias)
|
||||
- Useful before deleting flow to protect important generations
|
||||
|
||||
### 9.3 Validation Rules
|
||||
|
||||
**Use existing validation logic from generation creation:**
|
||||
- Prompt validation (existing rules)
|
||||
- AspectRatio validation (existing rules)
|
||||
- FlowId validation:
|
||||
- If provided (not null): must be valid UUID format
|
||||
- Flow does NOT need to exist (will be created eagerly if missing)
|
||||
- Allow null explicitly (for detachment)
|
||||
|
||||
### 9.4 Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "gen-uuid",
|
||||
"prompt": "updated prompt",
|
||||
"aspectRatio": "16:9",
|
||||
"flowId": null,
|
||||
"status": "processing", // If regeneration triggered
|
||||
"regenerated": true, // Flag indicating regeneration started
|
||||
"outputImage": { ... } // Current image (updates when regeneration completes)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Response Format Consistency
|
||||
|
||||
### 10.1 FlowId in Responses
|
||||
|
||||
**Rule for flowId in generation and upload responses:**
|
||||
|
||||
**If request has `flowId: undefined` (not provided):**
|
||||
- Generate new flowId
|
||||
- Return in response: `"flowId": "new-uuid"`
|
||||
|
||||
**If request has `flowId: null` (explicitly null):**
|
||||
- Do NOT generate flowId
|
||||
- Flow is definitely not needed
|
||||
- Return in response: `"flowId": null`
|
||||
|
||||
**If request has `flowId: "uuid"` (specific value):**
|
||||
- Use provided flowId
|
||||
- Return in response: `"flowId": "uuid"`
|
||||
|
||||
**Examples:**
|
||||
```json
|
||||
// Request without flowId
|
||||
POST /api/v1/generations
|
||||
Body: { "prompt": "sunset" }
|
||||
Response: { "flowId": "generated-uuid", ... }
|
||||
|
||||
// Request with explicit null
|
||||
POST /api/v1/generations
|
||||
Body: { "prompt": "sunset", "flowId": null }
|
||||
Response: { "flowId": null, ... }
|
||||
|
||||
// Request with specific flowId
|
||||
POST /api/v1/generations
|
||||
Body: { "prompt": "sunset", "flowId": "my-flow-uuid" }
|
||||
Response: { "flowId": "my-flow-uuid", ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Error Messages Updates
|
||||
|
||||
**Remove constants:**
|
||||
- `GENERATION_ALREADY_SUCCEEDED` (no longer needed)
|
||||
- `MAX_RETRY_COUNT_EXCEEDED` (no longer needed)
|
||||
|
||||
**Add constants:**
|
||||
- `SCOPE_INVALID_FORMAT` - "Invalid scope format. Use alphanumeric characters, hyphens, and underscores"
|
||||
- `SCOPE_CREATION_DISABLED` - "Creating new live scopes is disabled for this project"
|
||||
- `SCOPE_GENERATION_LIMIT_EXCEEDED` - "Scope generation limit exceeded. Maximum {limit} generations per scope"
|
||||
- `STORAGE_DELETE_FAILED` - "Failed to delete file from storage"
|
||||
|
||||
**Update constants:**
|
||||
- `GENERATION_FAILED` - include details about network/storage errors
|
||||
- `IMAGE_NOT_FOUND` - distinguish between deleted and never existed
|
||||
|
||||
---
|
||||
|
||||
## 12. Code Documentation Standards
|
||||
|
||||
### 12.1 Endpoint JSDoc Comments
|
||||
|
||||
**Requirement:** Every API endpoint must have comprehensive JSDoc comment.
|
||||
|
||||
**Required sections:**
|
||||
|
||||
1. **Purpose:** What this endpoint does (one sentence)
|
||||
2. **Logic:** Brief description of how it works (2-3 key steps)
|
||||
3. **Parameters:** Description of each parameter and what it affects
|
||||
4. **Authentication:** Required authentication level
|
||||
5. **Response:** What is returned
|
||||
|
||||
**Example format:**
|
||||
```typescript
|
||||
/**
|
||||
* Generate new image from text prompt with optional reference images.
|
||||
*
|
||||
* Logic:
|
||||
* 1. Parse prompt to auto-detect reference image aliases
|
||||
* 2. Resolve all aliases (auto-detected + manual) to image IDs
|
||||
* 3. Trigger AI generation with prompt and reference images
|
||||
* 4. Store result with metadata and return generation record
|
||||
*
|
||||
* @param {string} prompt - Text description for image generation (affects: output style and content)
|
||||
* @param {string[]} referenceImages - Optional aliases/IDs for reference images (affects: visual style transfer)
|
||||
* @param {string} aspectRatio - Image dimensions ratio (affects: output dimensions, default: 1:1)
|
||||
* @param {string} flowId - Optional flow association (affects: organization and flow-scoped aliases)
|
||||
* @param {string} alias - Optional project-scoped alias (affects: image referencing across project)
|
||||
* @param {string} flowAlias - Optional flow-scoped alias (affects: image referencing within flow)
|
||||
* @param {boolean} autoEnhance - Enable AI prompt enhancement (affects: prompt quality and detail)
|
||||
* @param {object} meta - Custom metadata (affects: searchability and organization)
|
||||
*
|
||||
* @authentication Project Key required
|
||||
* @returns {GenerationResponse} Generation record with status and output image details
|
||||
*/
|
||||
router.post('/generations', ...);
|
||||
```
|
||||
|
||||
**Apply to:**
|
||||
- All route handlers in `/routes/**/*.ts`
|
||||
- All public service methods that implement core business logic
|
||||
- Complex utility functions with non-obvious behavior
|
||||
|
||||
**Parameter descriptions must include "affects:"**
|
||||
- Explain what each parameter influences in the system
|
||||
- Help developers understand parameter impact
|
||||
- Make API more discoverable and self-documenting
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
### Database Changes
|
||||
1. Rename `enhancedPrompt` → `originalPrompt` in generations table
|
||||
2. Create `live_scopes` table with fields: id, project_id, slug, allowNewGenerations, newGenerationsLimit
|
||||
3. Add project settings: allowNewLiveScopes, newLiveScopesGenerationLimit
|
||||
4. Add `scope` and `isLiveUrl` fields to images table (optional, can use meta)
|
||||
|
||||
### API Changes
|
||||
1. Rename parameters: assignAlias → alias, assignFlowAlias → flowAlias
|
||||
2. Make referenceImages parameter optional with auto-detection from prompt
|
||||
3. Rename endpoint: POST /generations/:id/retry → /generations/:id/regenerate
|
||||
4. Remove endpoint: POST /api/v1/flows (no longer needed)
|
||||
5. Add endpoint: POST /api/v1/flows/:id/regenerate
|
||||
6. Add endpoint: PUT /api/v1/generations/:id (modification)
|
||||
7. Add CDN endpoints:
|
||||
- GET /cdn/:org/:project/img/:filenameOrAlias (all images)
|
||||
- GET /cdn/:org/:project/live/:scope (live URLs)
|
||||
8. Add scope management endpoints (CRUD for live_scopes)
|
||||
9. Update all image URLs in API responses to use CDN format
|
||||
|
||||
### Behavior Changes
|
||||
1. Lazy flow creation (create on second request or when flowAlias present)
|
||||
2. Alias conflict resolution (new overwrites old)
|
||||
3. Regenerate updates existing image (same ID, path, URL)
|
||||
4. Hard delete for images (with MinIO cleanup)
|
||||
5. Conditional delete for generations (based on alias)
|
||||
6. Cascade delete for flows (with alias protection)
|
||||
7. Live URL caching and scope management
|
||||
8. FlowId in responses (generate if undefined, keep if null)
|
||||
9. Auto-detect reference images from prompt aliases
|
||||
|
||||
### Validation Changes
|
||||
1. @ symbol distinguishes aliases from UUIDs
|
||||
2. Technical aliases forbidden in user input
|
||||
3. Flow creation on-the-fly for non-existent flowIds
|
||||
4. Scope format validation for live URLs
|
||||
|
||||
### Documentation Changes
|
||||
1. Add comprehensive JSDoc comments to all endpoints
|
||||
2. Include purpose, logic, parameters with "affects" descriptions
|
||||
3. Document authentication requirements in comments
|
||||
|
|
@ -43,10 +43,12 @@
|
|||
"@google/genai": "^1.22.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.2",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"express-validator": "^7.2.0",
|
||||
"helmet": "^8.0.0",
|
||||
"image-size": "^2.0.2",
|
||||
"mime": "3.0.0",
|
||||
"minio": "^8.0.6",
|
||||
"multer": "^2.0.2",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import express, { Application } from 'express';
|
||||
import cors from 'cors';
|
||||
import { config } from 'dotenv';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Config } from './types/api';
|
||||
import { textToImageRouter } from './routes/textToImage';
|
||||
import { imagesRouter } from './routes/images';
|
||||
import { uploadRouter } from './routes/upload';
|
||||
import bootstrapRoutes from './routes/bootstrap';
|
||||
import adminKeysRoutes from './routes/admin/keys';
|
||||
import { v1Router } from './routes/v1';
|
||||
import { cdnRouter } from './routes/cdn';
|
||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||
|
||||
// Load environment variables
|
||||
|
|
@ -42,7 +45,7 @@ export const createApp = (): Application => {
|
|||
|
||||
// Request ID middleware for logging
|
||||
app.use((req, res, next) => {
|
||||
req.requestId = Math.random().toString(36).substr(2, 9);
|
||||
req.requestId = randomUUID();
|
||||
res.setHeader('X-Request-ID', req.requestId);
|
||||
next();
|
||||
});
|
||||
|
|
@ -110,13 +113,19 @@ export const createApp = (): Application => {
|
|||
});
|
||||
|
||||
// Public routes (no authentication)
|
||||
// CDN routes for serving images and live URLs (public, no auth)
|
||||
app.use('/cdn', cdnRouter);
|
||||
|
||||
// Bootstrap route (no auth, but works only once)
|
||||
app.use('/api/bootstrap', bootstrapRoutes);
|
||||
|
||||
// Admin routes (require master key)
|
||||
app.use('/api/admin/keys', adminKeysRoutes);
|
||||
|
||||
// Protected API routes (require valid API key)
|
||||
// API v1 routes (versioned, require valid API key)
|
||||
app.use('/api/v1', v1Router);
|
||||
|
||||
// Protected API routes (require valid API key) - Legacy
|
||||
app.use('/api', textToImageRouter);
|
||||
app.use('/api', imagesRouter);
|
||||
app.use('/api', uploadRouter);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* IP-based rate limiter for live URL generation (Section 8.6)
|
||||
*
|
||||
* Limits: 10 new generations per hour per IP address
|
||||
* - Separate from API key rate limits
|
||||
* - Cache hits do NOT count toward limit
|
||||
* - Only new generations (cache MISS) count
|
||||
*
|
||||
* Implementation uses in-memory store with automatic cleanup
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number; // Timestamp when count resets
|
||||
}
|
||||
|
||||
// In-memory store for IP rate limits
|
||||
// Key: IP address, Value: { count, resetAt }
|
||||
const ipRateLimits = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Configuration
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const MAX_REQUESTS_PER_WINDOW = 10; // 10 new generations per hour
|
||||
|
||||
/**
|
||||
* Get client IP address from request
|
||||
* Supports X-Forwarded-For header for proxy/load balancer setups
|
||||
*/
|
||||
const getClientIp = (req: Request): string => {
|
||||
// Check X-Forwarded-For header (used by proxies/load balancers)
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
if (forwardedFor) {
|
||||
// X-Forwarded-For can contain multiple IPs, take the first one
|
||||
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
||||
return ips?.split(',')[0]?.trim() || req.ip || 'unknown';
|
||||
}
|
||||
|
||||
// Fall back to req.ip
|
||||
return req.ip || 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up expired entries from the rate limit store
|
||||
* Called periodically to prevent memory leaks
|
||||
*/
|
||||
const cleanupExpiredEntries = (): void => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of ipRateLimits.entries()) {
|
||||
if (now > entry.resetAt) {
|
||||
ipRateLimits.delete(ip);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run cleanup every 5 minutes
|
||||
setInterval(cleanupExpiredEntries, 5 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* Check if IP has exceeded rate limit
|
||||
* Returns true if limit exceeded, false otherwise
|
||||
*/
|
||||
export const checkIpRateLimit = (ip: string): boolean => {
|
||||
const now = Date.now();
|
||||
const entry = ipRateLimits.get(ip);
|
||||
|
||||
if (!entry) {
|
||||
// First request from this IP
|
||||
ipRateLimits.set(ip, {
|
||||
count: 1,
|
||||
resetAt: now + RATE_LIMIT_WINDOW_MS,
|
||||
});
|
||||
return false; // Not limited
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if (now > entry.resetAt) {
|
||||
// Reset the counter
|
||||
entry.count = 1;
|
||||
entry.resetAt = now + RATE_LIMIT_WINDOW_MS;
|
||||
return false; // Not limited
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
entry.count += 1;
|
||||
|
||||
// Check if limit exceeded
|
||||
return entry.count > MAX_REQUESTS_PER_WINDOW;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get remaining requests for IP
|
||||
*/
|
||||
export const getRemainingRequests = (ip: string): number => {
|
||||
const now = Date.now();
|
||||
const entry = ipRateLimits.get(ip);
|
||||
|
||||
if (!entry) {
|
||||
return MAX_REQUESTS_PER_WINDOW;
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if (now > entry.resetAt) {
|
||||
return MAX_REQUESTS_PER_WINDOW;
|
||||
}
|
||||
|
||||
return Math.max(0, MAX_REQUESTS_PER_WINDOW - entry.count);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get time until rate limit resets (in seconds)
|
||||
*/
|
||||
export const getResetTime = (ip: string): number => {
|
||||
const now = Date.now();
|
||||
const entry = ipRateLimits.get(ip);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.ceil((entry.resetAt - now) / 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware: IP-based rate limiter for live URLs
|
||||
* Only increments counter on cache MISS (new generation)
|
||||
* Use this middleware BEFORE cache check, but only increment after cache MISS
|
||||
*/
|
||||
export const ipRateLimiterMiddleware = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const ip = getClientIp(req);
|
||||
|
||||
// Attach IP to request for later use
|
||||
(req as any).clientIp = ip;
|
||||
|
||||
// Attach rate limit check function to request
|
||||
(req as any).checkIpRateLimit = () => {
|
||||
const limited = checkIpRateLimit(ip);
|
||||
if (limited) {
|
||||
const resetTime = getResetTime(ip);
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: `Rate limit exceeded. Try again in ${resetTime} seconds`,
|
||||
code: 'IP_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
});
|
||||
res.setHeader('Retry-After', resetTime.toString());
|
||||
res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
|
||||
res.setHeader('X-RateLimit-Remaining', '0');
|
||||
res.setHeader('X-RateLimit-Reset', getResetTime(ip).toString());
|
||||
return true; // Limited
|
||||
}
|
||||
return false; // Not limited
|
||||
};
|
||||
|
||||
// Set rate limit headers
|
||||
const remaining = getRemainingRequests(ip);
|
||||
const resetTime = getResetTime(ip);
|
||||
res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
|
||||
res.setHeader('X-RateLimit-Remaining', remaining.toString());
|
||||
if (resetTime > 0) {
|
||||
res.setHeader('X-RateLimit-Reset', resetTime.toString());
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to manually increment IP rate limit counter
|
||||
* Use this after confirming cache MISS (new generation)
|
||||
*/
|
||||
export const incrementIpRateLimit = (_ip: string): void => {
|
||||
// Counter already incremented in checkIpRateLimit
|
||||
// This is a no-op, kept for API consistency
|
||||
};
|
||||
|
|
@ -81,8 +81,6 @@ export const autoEnhancePrompt = async (
|
|||
}),
|
||||
enhancements: result.metadata?.enhancements || [],
|
||||
};
|
||||
|
||||
req.body.prompt = result.enhancedPrompt;
|
||||
} else {
|
||||
console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`);
|
||||
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
|
||||
|
|
|
|||
|
|
@ -112,20 +112,25 @@ router.get('/', async (req, res) => {
|
|||
try {
|
||||
const keys = await apiKeyService.listKeys();
|
||||
|
||||
// Don't expose key hashes
|
||||
// Format response with nested objects, ISO dates, and no sensitive data
|
||||
const safeKeys = keys.map((key) => ({
|
||||
id: key.id,
|
||||
type: key.keyType,
|
||||
projectId: key.projectId,
|
||||
name: key.name,
|
||||
scopes: key.scopes,
|
||||
isActive: key.isActive,
|
||||
createdAt: key.createdAt,
|
||||
expiresAt: key.expiresAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
createdAt: key.createdAt.toISOString(),
|
||||
expiresAt: key.expiresAt ? key.expiresAt.toISOString() : null,
|
||||
lastUsedAt: key.lastUsedAt ? key.lastUsedAt.toISOString() : null,
|
||||
createdBy: key.createdBy,
|
||||
organization: key.organization,
|
||||
project: key.project,
|
||||
}));
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] API keys listed by admin: ${req.apiKey!.id} - returned ${safeKeys.length} keys`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
keys: safeKeys,
|
||||
total: safeKeys.length,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,481 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { db } from '@/db';
|
||||
import { organizations, projects, images } from '@banatie/database';
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||
import { ImageService, GenerationService, LiveScopeService } from '@/services/core';
|
||||
import { StorageFactory } from '@/services/StorageFactory';
|
||||
import { asyncHandler } from '@/middleware/errorHandler';
|
||||
import { ipRateLimiterMiddleware } from '@/middleware/ipRateLimiter';
|
||||
import { computeLiveUrlCacheKey } from '@/utils/helpers';
|
||||
import { GENERATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
|
||||
import type { LiveGenerationQuery } from '@/types/requests';
|
||||
|
||||
export const cdnRouter: RouterType = Router();
|
||||
|
||||
let imageService: ImageService;
|
||||
let generationService: GenerationService;
|
||||
let liveScopeService: LiveScopeService;
|
||||
|
||||
const getImageService = (): ImageService => {
|
||||
if (!imageService) {
|
||||
imageService = new ImageService();
|
||||
}
|
||||
return imageService;
|
||||
};
|
||||
|
||||
const getGenerationService = (): GenerationService => {
|
||||
if (!generationService) {
|
||||
generationService = new GenerationService();
|
||||
}
|
||||
return generationService;
|
||||
};
|
||||
|
||||
const getLiveScopeService = (): LiveScopeService => {
|
||||
if (!liveScopeService) {
|
||||
liveScopeService = new LiveScopeService();
|
||||
}
|
||||
return liveScopeService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve images by filename or project-scoped alias via public CDN
|
||||
*
|
||||
* Public CDN endpoint for serving images without authentication:
|
||||
* - Supports filename-based access (exact match in storageKey)
|
||||
* - Supports project-scoped alias access (@alias-name)
|
||||
* - Returns raw image bytes with optimal caching headers
|
||||
* - Long-term browser caching (1 year max-age)
|
||||
* - No rate limiting (public access)
|
||||
*
|
||||
* URL structure matches MinIO storage organization for efficient lookups.
|
||||
*
|
||||
* @route GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias
|
||||
* @authentication None - Public endpoint
|
||||
*
|
||||
* @param {string} req.params.orgSlug - Organization slug
|
||||
* @param {string} req.params.projectSlug - Project slug
|
||||
* @param {string} req.params.filenameOrAlias - Filename or @alias
|
||||
*
|
||||
* @returns {Buffer} 200 - Image file bytes with Content-Type header
|
||||
* @returns {object} 404 - Organization, project, or image not found
|
||||
* @returns {object} 500 - CDN or storage error
|
||||
*
|
||||
* @throws {Error} ORG_NOT_FOUND - Organization does not exist
|
||||
* @throws {Error} PROJECT_NOT_FOUND - Project does not exist
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Image not found
|
||||
* @throws {Error} CDN_ERROR - General CDN error
|
||||
*
|
||||
* @example
|
||||
* // Access by filename
|
||||
* GET /cdn/acme/website/img/hero-background.jpg
|
||||
*
|
||||
* @example
|
||||
* // Access by alias
|
||||
* GET /cdn/acme/website/img/@hero
|
||||
*
|
||||
* Response Headers:
|
||||
* Content-Type: image/jpeg
|
||||
* Content-Length: 245810
|
||||
* Cache-Control: public, max-age=31536000
|
||||
* X-Image-Id: 550e8400-e29b-41d4-a716-446655440000
|
||||
*/
|
||||
cdnRouter.get(
|
||||
'/:orgSlug/:projectSlug/img/:filenameOrAlias',
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const { orgSlug, projectSlug, filenameOrAlias } = req.params;
|
||||
|
||||
try {
|
||||
// Resolve organization and project
|
||||
const org = await db.query.organizations.findFirst({
|
||||
where: eq(organizations.slug, orgSlug),
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Organization not found', code: 'ORG_NOT_FOUND' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: and(eq(projects.slug, projectSlug), eq(projects.organizationId, org.id)),
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Project not found', code: 'PROJECT_NOT_FOUND' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let image;
|
||||
|
||||
// Check if filenameOrAlias is an alias (starts with @)
|
||||
if (filenameOrAlias.startsWith('@')) {
|
||||
// Lookup by project-scoped alias
|
||||
const allImages = await db.query.images.findMany({
|
||||
where: and(
|
||||
eq(images.projectId, project.id),
|
||||
eq(images.alias, filenameOrAlias),
|
||||
isNull(images.deletedAt),
|
||||
),
|
||||
});
|
||||
|
||||
image = allImages[0] || null;
|
||||
} else {
|
||||
// Lookup by filename in storageKey
|
||||
const allImages = await db.query.images.findMany({
|
||||
where: and(eq(images.projectId, project.id), isNull(images.deletedAt)),
|
||||
});
|
||||
|
||||
// Find image where storageKey ends with filename
|
||||
image = allImages.find((img) => img.storageKey.includes(filenameOrAlias)) || null;
|
||||
}
|
||||
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: { message: ERROR_MESSAGES.IMAGE_NOT_FOUND, code: 'IMAGE_NOT_FOUND' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Download image from storage
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
const keyParts = image.storageKey.split('/');
|
||||
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
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(orgId, projectId, category, filename);
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', image.mimeType);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||
res.setHeader('X-Image-Id', image.id);
|
||||
|
||||
// Stream image bytes
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
console.error('CDN image serve error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Failed to serve image',
|
||||
code: 'CDN_ERROR',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Live URL generation with automatic caching and scope management
|
||||
*
|
||||
* Public endpoint for on-demand image generation via URL parameters:
|
||||
* - No authentication required (public access)
|
||||
* - Automatic prompt-based caching (cache key computed from params)
|
||||
* - IP-based rate limiting (10 new generations per hour)
|
||||
* - Scope-based generation limits
|
||||
* - Lazy scope creation (automatic on first use)
|
||||
* - Returns raw image bytes with caching headers
|
||||
*
|
||||
* Cache behavior:
|
||||
* - Cache HIT: Returns existing image instantly, no rate limit check
|
||||
* - Cache MISS: Generates new image, counts toward IP rate limit
|
||||
* - Cache key computed from: prompt + aspectRatio + autoEnhance + template
|
||||
* - Cached images stored with meta.isLiveUrl = true
|
||||
*
|
||||
* Scope management:
|
||||
* - Scopes separate generation budgets (e.g., "hero", "gallery")
|
||||
* - Auto-created on first use if allowNewLiveScopes = true
|
||||
* - Generation limits per scope (default: 30)
|
||||
* - Scope stats tracked (currentGenerations, lastGeneratedAt)
|
||||
*
|
||||
* @route GET /cdn/:orgSlug/:projectSlug/live/:scope
|
||||
* @authentication None - Public endpoint
|
||||
* @rateLimit 10 new generations per hour per IP (cache hits excluded)
|
||||
*
|
||||
* @param {string} req.params.orgSlug - Organization slug
|
||||
* @param {string} req.params.projectSlug - Project slug
|
||||
* @param {string} req.params.scope - Scope identifier (alphanumeric + hyphens + underscores)
|
||||
* @param {string} req.query.prompt - Image description (required)
|
||||
* @param {string} [req.query.aspectRatio='1:1'] - Aspect ratio (1:1, 16:9, 3:2, 9:16)
|
||||
* @param {boolean} [req.query.autoEnhance=false] - Enable prompt enhancement
|
||||
* @param {string} [req.query.template] - Enhancement template (photorealistic, illustration, etc.)
|
||||
*
|
||||
* @returns {Buffer} 200 - Image file bytes with headers
|
||||
* @returns {object} 400 - Missing/invalid prompt or scope format
|
||||
* @returns {object} 403 - Scope creation disabled
|
||||
* @returns {object} 404 - Organization or project not found
|
||||
* @returns {object} 429 - Rate limit or scope generation limit exceeded
|
||||
* @returns {object} 500 - Generation or storage error
|
||||
*
|
||||
* @throws {Error} VALIDATION_ERROR - Prompt is required
|
||||
* @throws {Error} SCOPE_INVALID_FORMAT - Invalid scope format
|
||||
* @throws {Error} ORG_NOT_FOUND - Organization does not exist
|
||||
* @throws {Error} PROJECT_NOT_FOUND - Project does not exist
|
||||
* @throws {Error} SCOPE_CREATION_DISABLED - New scope creation not allowed
|
||||
* @throws {Error} SCOPE_GENERATION_LIMIT_EXCEEDED - Scope limit reached
|
||||
* @throws {Error} IP_RATE_LIMIT_EXCEEDED - IP rate limit exceeded
|
||||
* @throws {Error} LIVE_URL_ERROR - General generation error
|
||||
*
|
||||
* @example
|
||||
* // Basic generation with caching
|
||||
* GET /cdn/acme/website/live/hero-section?prompt=mountain+landscape&aspectRatio=16:9
|
||||
*
|
||||
* @example
|
||||
* // With auto-enhancement
|
||||
* GET /cdn/acme/website/live/gallery?prompt=product+photo&autoEnhance=true&template=product
|
||||
*
|
||||
* Response Headers (Cache HIT):
|
||||
* Content-Type: image/jpeg
|
||||
* Content-Length: 245810
|
||||
* Cache-Control: public, max-age=31536000
|
||||
* X-Cache-Status: HIT
|
||||
* X-Scope: hero-section
|
||||
* X-Image-Id: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* Response Headers (Cache MISS):
|
||||
* Content-Type: image/jpeg
|
||||
* Content-Length: 198234
|
||||
* Cache-Control: public, max-age=31536000
|
||||
* X-Cache-Status: MISS
|
||||
* X-Scope: hero-section
|
||||
* X-Generation-Id: 660e8400-e29b-41d4-a716-446655440001
|
||||
* X-Image-Id: 770e8400-e29b-41d4-a716-446655440002
|
||||
* X-RateLimit-Limit: 10
|
||||
* X-RateLimit-Remaining: 9
|
||||
* X-RateLimit-Reset: 3456
|
||||
*/
|
||||
cdnRouter.get(
|
||||
'/:orgSlug/:projectSlug/live/:scope',
|
||||
ipRateLimiterMiddleware,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const { orgSlug, projectSlug, scope } = req.params;
|
||||
const { prompt, aspectRatio, autoEnhance, template } = req.query as LiveGenerationQuery;
|
||||
|
||||
const genService = getGenerationService();
|
||||
const imgService = getImageService();
|
||||
const scopeService = getLiveScopeService();
|
||||
|
||||
try {
|
||||
// Validate prompt
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Prompt is required', code: 'VALIDATION_ERROR' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate scope format (alphanumeric + hyphens + underscores)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(scope)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: { message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT, code: 'SCOPE_INVALID_FORMAT' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve organization
|
||||
const org = await db.query.organizations.findFirst({
|
||||
where: eq(organizations.slug, orgSlug),
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Organization not found', code: 'ORG_NOT_FOUND' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve project
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: and(eq(projects.slug, projectSlug), eq(projects.organizationId, org.id)),
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Project not found', code: 'PROJECT_NOT_FOUND' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute cache key
|
||||
const normalizedAutoEnhance =
|
||||
typeof autoEnhance === 'string' ? autoEnhance === 'true' : Boolean(autoEnhance);
|
||||
|
||||
const cacheParams: {
|
||||
aspectRatio?: string;
|
||||
autoEnhance?: boolean;
|
||||
template?: string;
|
||||
} = {};
|
||||
if (aspectRatio) cacheParams.aspectRatio = aspectRatio as string;
|
||||
if (autoEnhance !== undefined) cacheParams.autoEnhance = normalizedAutoEnhance;
|
||||
if (template) cacheParams.template = template as string;
|
||||
|
||||
const cacheKey = computeLiveUrlCacheKey(project.id, scope, prompt, cacheParams);
|
||||
|
||||
// Check cache: find image with meta.liveUrlCacheKey = cacheKey
|
||||
const cachedImages = await db.query.images.findMany({
|
||||
where: and(
|
||||
eq(images.projectId, project.id),
|
||||
isNull(images.deletedAt),
|
||||
sql`${images.meta}->>'scope' = ${scope}`,
|
||||
sql`${images.meta}->>'isLiveUrl' = 'true'`,
|
||||
sql`${images.meta}->>'cacheKey' = ${cacheKey}`,
|
||||
),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const cachedImage = cachedImages[0];
|
||||
|
||||
if (cachedImage) {
|
||||
// Cache HIT - serve existing image
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
const keyParts = cachedImage.storageKey.split('/');
|
||||
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
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(orgId, projectId, category, filename);
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', cachedImage.mimeType);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||
res.setHeader('X-Cache-Status', 'HIT');
|
||||
res.setHeader('X-Scope', scope);
|
||||
res.setHeader('X-Image-Id', cachedImage.id);
|
||||
|
||||
res.send(buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache MISS - check IP rate limit before generating
|
||||
// Only count new generations (cache MISS) toward IP rate limit
|
||||
const isLimited = (req as any).checkIpRateLimit();
|
||||
if (isLimited) {
|
||||
return; // Rate limit response already sent
|
||||
}
|
||||
|
||||
// Cache MISS - check scope and generate
|
||||
// Get or create scope
|
||||
let liveScope;
|
||||
try {
|
||||
liveScope = await scopeService.createOrGet(project.id, scope, {
|
||||
allowNewLiveScopes: project.allowNewLiveScopes,
|
||||
newLiveScopesGenerationLimit: project.newLiveScopesGenerationLimit,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === ERROR_MESSAGES.SCOPE_CREATION_DISABLED) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.SCOPE_CREATION_DISABLED,
|
||||
code: 'SCOPE_CREATION_DISABLED',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if scope allows new generations
|
||||
const scopeStats = await scopeService.getByIdWithStats(liveScope.id);
|
||||
const canGenerate = await scopeService.canGenerateNew(
|
||||
liveScope,
|
||||
scopeStats.currentGenerations,
|
||||
);
|
||||
|
||||
if (!canGenerate) {
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.SCOPE_GENERATION_LIMIT_EXCEEDED,
|
||||
code: 'SCOPE_GENERATION_LIMIT_EXCEEDED',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new image (no API key, use system generation)
|
||||
const generation = await genService.create({
|
||||
projectId: project.id,
|
||||
apiKeyId: null as unknown as string, // System generation for live URLs
|
||||
prompt,
|
||||
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
autoEnhance: normalizedAutoEnhance,
|
||||
requestId: `live-${scope}-${Date.now()}`,
|
||||
});
|
||||
|
||||
if (!generation.outputImage) {
|
||||
throw new Error('Generation succeeded but no output image was created');
|
||||
}
|
||||
|
||||
// Update image meta to mark as live URL with cache key and scope
|
||||
await imgService.update(generation.outputImage.id, {
|
||||
meta: {
|
||||
...generation.outputImage.meta,
|
||||
scope,
|
||||
isLiveUrl: true,
|
||||
cacheKey,
|
||||
},
|
||||
});
|
||||
|
||||
// Download newly generated image
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
const keyParts = generation.outputImage.storageKey.split('/');
|
||||
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
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(orgId, projectId, category, filename);
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', generation.outputImage.mimeType);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||
res.setHeader('X-Cache-Status', 'MISS');
|
||||
res.setHeader('X-Scope', scope);
|
||||
res.setHeader('X-Generation-Id', generation.id);
|
||||
res.setHeader('X-Image-Id', generation.outputImage.id);
|
||||
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
console.error('Live URL generation error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Generation failed',
|
||||
code: 'LIVE_URL_ERROR',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,630 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { FlowService, GenerationService } from '@/services/core';
|
||||
import { asyncHandler } from '@/middleware/errorHandler';
|
||||
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||
import { validateAndNormalizePagination } from '@/utils/validators';
|
||||
import { buildPaginatedResponse } from '@/utils/helpers';
|
||||
import { toFlowResponse, toGenerationResponse, toImageResponse } from '@/types/responses';
|
||||
import type {
|
||||
CreateFlowResponse,
|
||||
ListFlowsResponse,
|
||||
GetFlowResponse,
|
||||
UpdateFlowAliasesResponse,
|
||||
ListFlowGenerationsResponse,
|
||||
ListFlowImagesResponse,
|
||||
} from '@/types/responses';
|
||||
|
||||
export const flowsRouter: RouterType = Router();
|
||||
|
||||
let flowService: FlowService;
|
||||
let generationService: GenerationService;
|
||||
|
||||
const getFlowService = (): FlowService => {
|
||||
if (!flowService) {
|
||||
flowService = new FlowService();
|
||||
}
|
||||
return flowService;
|
||||
};
|
||||
|
||||
const getGenerationService = (): GenerationService => {
|
||||
if (!generationService) {
|
||||
generationService = new GenerationService();
|
||||
}
|
||||
return generationService;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/v1/flows
|
||||
* REMOVED (Section 4.3): Lazy flow creation pattern
|
||||
* Flows are now created automatically when:
|
||||
* - A generation/upload specifies a flowId
|
||||
* - A generation/upload provides a flowAlias (eager creation)
|
||||
*
|
||||
* @deprecated Flows are created automatically, no explicit endpoint needed
|
||||
*/
|
||||
// flowsRouter.post(
|
||||
// '/',
|
||||
// validateApiKey,
|
||||
// requireProjectKey,
|
||||
// asyncHandler(async (req: any, res: Response<CreateFlowResponse>) => {
|
||||
// const service = getFlowService();
|
||||
// const { meta } = req.body;
|
||||
//
|
||||
// const projectId = req.apiKey.projectId;
|
||||
//
|
||||
// const flow = await service.create({
|
||||
// projectId,
|
||||
// aliases: {},
|
||||
// meta: meta || {},
|
||||
// });
|
||||
//
|
||||
// res.status(201).json({
|
||||
// success: true,
|
||||
// data: toFlowResponse(flow),
|
||||
// });
|
||||
// })
|
||||
// );
|
||||
|
||||
/**
|
||||
* List all flows for a project with pagination and computed counts
|
||||
*
|
||||
* Retrieves flows created automatically when generations/uploads specify:
|
||||
* - A flowId in their request
|
||||
* - A flowAlias (creates flow eagerly if doesn't exist)
|
||||
*
|
||||
* Each flow includes:
|
||||
* - Computed generationCount and imageCount
|
||||
* - Flow-scoped aliases (JSONB key-value pairs)
|
||||
* - Custom metadata
|
||||
*
|
||||
* @route GET /api/v1/flows
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||
*
|
||||
* @returns {ListFlowsResponse} 200 - Paginated list of flows with counts
|
||||
* @returns {object} 400 - Invalid pagination parameters
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/flows?limit=50&offset=0
|
||||
*/
|
||||
flowsRouter.get(
|
||||
'/',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<ListFlowsResponse>) => {
|
||||
const service = getFlowService();
|
||||
const { limit, offset } = req.query;
|
||||
|
||||
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||
if (!paginationResult.valid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
const result = await service.list(
|
||||
{ projectId },
|
||||
validatedLimit,
|
||||
validatedOffset
|
||||
);
|
||||
|
||||
const responseData = result.flows.map((flow) => toFlowResponse(flow));
|
||||
|
||||
res.json(
|
||||
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a single flow by ID with computed statistics
|
||||
*
|
||||
* Retrieves detailed flow information including:
|
||||
* - All flow-scoped aliases
|
||||
* - Computed generationCount (active generations only)
|
||||
* - Computed imageCount (active images only)
|
||||
* - Custom metadata
|
||||
* - Creation and update timestamps
|
||||
*
|
||||
* @route GET /api/v1/flows/:id
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id - Flow ID (UUID)
|
||||
*
|
||||
* @returns {GetFlowResponse} 200 - Complete flow details with counts
|
||||
* @returns {object} 404 - Flow not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000
|
||||
*/
|
||||
flowsRouter.get(
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<GetFlowResponse>) => {
|
||||
const service = getFlowService();
|
||||
const { id } = req.params;
|
||||
|
||||
const flow = await service.getByIdWithCounts(id);
|
||||
|
||||
if (flow.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow not found',
|
||||
code: 'FLOW_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toFlowResponse(flow),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* List all generations in a specific flow with pagination
|
||||
*
|
||||
* Retrieves all generations associated with this flow, ordered by creation date (newest first).
|
||||
* Includes only active (non-deleted) generations.
|
||||
*
|
||||
* @route GET /api/v1/flows/:id/generations
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id - Flow ID (UUID)
|
||||
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||
*
|
||||
* @returns {ListFlowGenerationsResponse} 200 - Paginated list of generations
|
||||
* @returns {object} 404 - Flow not found or access denied
|
||||
* @returns {object} 400 - Invalid pagination parameters
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/generations?limit=10
|
||||
*/
|
||||
flowsRouter.get(
|
||||
'/:id/generations',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<ListFlowGenerationsResponse>) => {
|
||||
const service = getFlowService();
|
||||
const { id } = req.params;
|
||||
const { limit, offset } = req.query;
|
||||
|
||||
const flow = await service.getById(id);
|
||||
if (!flow) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||
if (!paginationResult.valid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||
|
||||
const result = await service.getFlowGenerations(id, validatedLimit, validatedOffset);
|
||||
|
||||
const responseData = result.generations.map((gen) => toGenerationResponse(gen));
|
||||
|
||||
res.json(
|
||||
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* List all images in a specific flow with pagination
|
||||
*
|
||||
* Retrieves all images (generated and uploaded) associated with this flow,
|
||||
* ordered by creation date (newest first). Includes only active (non-deleted) images.
|
||||
*
|
||||
* @route GET /api/v1/flows/:id/images
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id - Flow ID (UUID)
|
||||
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||
*
|
||||
* @returns {ListFlowImagesResponse} 200 - Paginated list of images
|
||||
* @returns {object} 404 - Flow not found or access denied
|
||||
* @returns {object} 400 - Invalid pagination parameters
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/images?limit=20
|
||||
*/
|
||||
flowsRouter.get(
|
||||
'/:id/images',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<ListFlowImagesResponse>) => {
|
||||
const service = getFlowService();
|
||||
const { id } = req.params;
|
||||
const { limit, offset } = req.query;
|
||||
|
||||
const flow = await service.getById(id);
|
||||
if (!flow) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||
if (!paginationResult.valid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||
|
||||
const result = await service.getFlowImages(id, validatedLimit, validatedOffset);
|
||||
|
||||
const responseData = result.images.map((img) => toImageResponse(img));
|
||||
|
||||
res.json(
|
||||
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Update flow-scoped aliases (add or modify existing)
|
||||
*
|
||||
* Updates the JSONB aliases field with new or modified key-value pairs.
|
||||
* Aliases are merged with existing aliases (does not replace all).
|
||||
*
|
||||
* Flow-scoped aliases:
|
||||
* - Must start with @ symbol
|
||||
* - Unique within the flow only (not project-wide)
|
||||
* - Used for alias resolution in generations
|
||||
* - Stored as JSONB for efficient lookups
|
||||
*
|
||||
* @route PUT /api/v1/flows/:id/aliases
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id - Flow ID (UUID)
|
||||
* @param {UpdateFlowAliasesRequest} req.body - Alias updates
|
||||
* @param {object} req.body.aliases - Key-value pairs of aliases to add/update
|
||||
*
|
||||
* @returns {UpdateFlowAliasesResponse} 200 - Updated flow with merged aliases
|
||||
* @returns {object} 404 - Flow not found or access denied
|
||||
* @returns {object} 400 - Invalid aliases format
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||
* @throws {Error} VALIDATION_ERROR - Aliases must be an object
|
||||
*
|
||||
* @example
|
||||
* PUT /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/aliases
|
||||
* {
|
||||
* "aliases": {
|
||||
* "@hero": "image-id-123",
|
||||
* "@background": "image-id-456"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
flowsRouter.put(
|
||||
'/:id/aliases',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<UpdateFlowAliasesResponse>) => {
|
||||
const service = getFlowService();
|
||||
const { id } = req.params;
|
||||
const { aliases } = req.body;
|
||||
|
||||
if (!aliases || typeof aliases !== 'object' || Array.isArray(aliases)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Aliases must be an object with key-value pairs',
|
||||
code: 'VALIDATION_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const flow = await service.getById(id);
|
||||
if (!flow) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow not found',
|
||||
code: 'FLOW_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow not found',
|
||||
code: 'FLOW_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedFlow = await service.updateAliases(id, aliases);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toFlowResponse(updatedFlow),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove a specific alias from a flow
|
||||
*
|
||||
* Deletes a single alias key-value pair from the flow's JSONB aliases field.
|
||||
* Other aliases remain unchanged.
|
||||
*
|
||||
* @route DELETE /api/v1/flows/:id/aliases/:alias
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id - Flow ID (UUID)
|
||||
* @param {string} req.params.alias - Alias to remove (e.g., "@hero")
|
||||
*
|
||||
* @returns {object} 200 - Updated flow with alias removed
|
||||
* @returns {object} 404 - Flow not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||
*
|
||||
* @example
|
||||
* DELETE /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/aliases/@hero
|
||||
*/
|
||||
flowsRouter.delete(
|
||||
'/:id/aliases/:alias',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const service = getFlowService();
|
||||
const { id, alias } = req.params;
|
||||
|
||||
const flow = await service.getById(id);
|
||||
if (!flow) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow not found',
|
||||
code: 'FLOW_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow not found',
|
||||
code: 'FLOW_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedFlow = await service.removeAlias(id, alias);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toFlowResponse(updatedFlow),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Regenerate the most recent generation in a flow (Section 3.6)
|
||||
*
|
||||
* Logic:
|
||||
* 1. Find the flow by ID
|
||||
* 2. Query for the most recent generation (ordered by createdAt desc)
|
||||
* 3. Trigger regeneration with exact same parameters
|
||||
* 4. Replace existing output image (preserves ID and URLs)
|
||||
*
|
||||
* @route POST /api/v1/flows/:id/regenerate
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {string} req.params.id - Flow ID (affects: determines which flow's latest generation to regenerate)
|
||||
*
|
||||
* @returns {object} 200 - Regenerated generation with updated output image
|
||||
* @returns {object} 404 - Flow not found or access denied
|
||||
* @returns {object} 400 - Flow has no generations
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||
* @throws {Error} FLOW_HAS_NO_GENERATIONS - Flow contains no generations to regenerate
|
||||
*
|
||||
* @example
|
||||
* POST /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/regenerate
|
||||
*/
|
||||
flowsRouter.post(
|
||||
'/:id/regenerate',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const flowSvc = getFlowService();
|
||||
const genSvc = getGenerationService();
|
||||
const { id } = req.params;
|
||||
|
||||
const flow = await flowSvc.getById(id);
|
||||
if (!flow) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow not found',
|
||||
code: 'FLOW_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow not found',
|
||||
code: 'FLOW_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the most recent generation in the flow
|
||||
const result = await flowSvc.getFlowGenerations(id, 1, 0); // limit=1, offset=0
|
||||
|
||||
if (result.total === 0 || result.generations.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow has no generations to regenerate',
|
||||
code: 'FLOW_HAS_NO_GENERATIONS',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const latestGeneration = result.generations[0]!;
|
||||
|
||||
// Regenerate the latest generation
|
||||
const regenerated = await genSvc.regenerate(latestGeneration.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toGenerationResponse(regenerated),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete a flow with cascade deletion (Section 7.3)
|
||||
*
|
||||
* Permanently removes the flow with cascade behavior:
|
||||
* - Flow record is hard deleted
|
||||
* - All generations in flow are hard deleted
|
||||
* - Images WITHOUT project alias: hard deleted with MinIO cleanup
|
||||
* - Images WITH project alias: kept, but flowId set to NULL (unlinked)
|
||||
*
|
||||
* Rationale: Images with project aliases are used globally and should be preserved.
|
||||
* Flow deletion removes the organizational structure but protects important assets.
|
||||
*
|
||||
* @route DELETE /api/v1/flows/:id
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id - Flow ID (UUID)
|
||||
*
|
||||
* @returns {object} 200 - Deletion confirmation with flow ID
|
||||
* @returns {object} 404 - Flow not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} FLOW_NOT_FOUND - Flow does not exist
|
||||
*
|
||||
* @example
|
||||
* DELETE /api/v1/flows/550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "success": true,
|
||||
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||
* }
|
||||
*/
|
||||
flowsRouter.delete(
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const service = getFlowService();
|
||||
const { id } = req.params;
|
||||
|
||||
const flow = await service.getById(id);
|
||||
if (!flow) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow not found',
|
||||
code: 'FLOW_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Flow not found',
|
||||
code: 'FLOW_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await service.delete(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { id },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
@ -0,0 +1,549 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { GenerationService } from '@/services/core';
|
||||
import { asyncHandler } from '@/middleware/errorHandler';
|
||||
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||
import { autoEnhancePrompt } from '@/middleware/promptEnhancement';
|
||||
import { validateAndNormalizePagination } from '@/utils/validators';
|
||||
import { buildPaginatedResponse } from '@/utils/helpers';
|
||||
import { toGenerationResponse } from '@/types/responses';
|
||||
import type {
|
||||
CreateGenerationResponse,
|
||||
ListGenerationsResponse,
|
||||
GetGenerationResponse,
|
||||
} from '@/types/responses';
|
||||
|
||||
export const generationsRouter: RouterType = Router();
|
||||
|
||||
let generationService: GenerationService;
|
||||
|
||||
const getGenerationService = (): GenerationService => {
|
||||
if (!generationService) {
|
||||
generationService = new GenerationService();
|
||||
}
|
||||
return generationService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new image generation from a text prompt
|
||||
*
|
||||
* Generates AI-powered images using Gemini Flash Image model with support for:
|
||||
* - Text prompts with optional auto-enhancement
|
||||
* - Reference images for style/context
|
||||
* - Flow association and flow-scoped aliases
|
||||
* - Project-scoped aliases for direct access
|
||||
* - Custom metadata storage
|
||||
*
|
||||
* @route POST /api/v1/generations
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {CreateGenerationRequest} req.body - Generation parameters
|
||||
* @param {string} req.body.prompt - Text description of desired image (required)
|
||||
* @param {string[]} [req.body.referenceImages] - Array of aliases to use as references
|
||||
* @param {string} [req.body.aspectRatio='1:1'] - Aspect ratio (1:1, 16:9, 3:2, 9:16)
|
||||
* @param {string} [req.body.flowId] - Associate with existing flow
|
||||
* @param {string} [req.body.alias] - Project-scoped alias (@custom-name)
|
||||
* @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId)
|
||||
* @param {boolean} [req.body.autoEnhance=true] - Enable prompt enhancement
|
||||
* @param {object} [req.body.meta] - Custom metadata
|
||||
*
|
||||
* @returns {CreateGenerationResponse} 201 - Generation created with status
|
||||
* @returns {object} 400 - Invalid request parameters
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @throws {Error} VALIDATION_ERROR - Missing or invalid prompt
|
||||
* @throws {Error} ALIAS_CONFLICT - Alias already exists
|
||||
* @throws {Error} FLOW_NOT_FOUND - Flow ID does not exist
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Reference image alias not found
|
||||
*
|
||||
* @example
|
||||
* // Basic generation
|
||||
* POST /api/v1/generations
|
||||
* {
|
||||
* "prompt": "A serene mountain landscape at sunset",
|
||||
* "aspectRatio": "16:9"
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // With reference images and alias
|
||||
* POST /api/v1/generations
|
||||
* {
|
||||
* "prompt": "Product photo in this style",
|
||||
* "referenceImages": ["@brand-style", "@product-template"],
|
||||
* "alias": "@hero-image",
|
||||
* "autoEnhance": true
|
||||
* }
|
||||
*/
|
||||
generationsRouter.post(
|
||||
'/',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
autoEnhancePrompt,
|
||||
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
|
||||
const service = getGenerationService();
|
||||
|
||||
// Extract original prompt from middleware property if enhancement was attempted
|
||||
// Otherwise fall back to request body
|
||||
const prompt = req.originalPrompt || req.body.prompt;
|
||||
|
||||
const {
|
||||
referenceImages,
|
||||
aspectRatio,
|
||||
flowId,
|
||||
alias,
|
||||
flowAlias,
|
||||
autoEnhance,
|
||||
meta,
|
||||
} = req.body;
|
||||
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Prompt is required and must be a string',
|
||||
code: 'VALIDATION_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.apiKey.projectId;
|
||||
const apiKeyId = req.apiKey.id;
|
||||
|
||||
const generation = await service.create({
|
||||
projectId,
|
||||
apiKeyId,
|
||||
prompt,
|
||||
referenceImages,
|
||||
aspectRatio,
|
||||
flowId,
|
||||
alias,
|
||||
flowAlias,
|
||||
autoEnhance,
|
||||
enhancedPrompt: req.enhancedPrompt,
|
||||
meta,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: toGenerationResponse(generation),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* List all generations for the project with filtering and pagination
|
||||
*
|
||||
* Retrieves generations with support for:
|
||||
* - Flow-based filtering
|
||||
* - Status filtering (pending, processing, success, failed)
|
||||
* - Pagination with configurable limit and offset
|
||||
* - Optional inclusion of soft-deleted generations
|
||||
*
|
||||
* @route GET /api/v1/generations
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} [req.query.flowId] - Filter by flow ID
|
||||
* @param {string} [req.query.status] - Filter by status (pending|processing|success|failed)
|
||||
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||
* @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted generations
|
||||
*
|
||||
* @returns {ListGenerationsResponse} 200 - Paginated list of generations
|
||||
* @returns {object} 400 - Invalid pagination parameters
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @example
|
||||
* // List recent generations
|
||||
* GET /api/v1/generations?limit=10&offset=0
|
||||
*
|
||||
* @example
|
||||
* // Filter by flow and status
|
||||
* GET /api/v1/generations?flowId=abc-123&status=success&limit=50
|
||||
*/
|
||||
generationsRouter.get(
|
||||
'/',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<ListGenerationsResponse>) => {
|
||||
const service = getGenerationService();
|
||||
const { flowId, status, limit, offset, includeDeleted } = req.query;
|
||||
|
||||
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||
if (!paginationResult.valid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
const result = await service.list(
|
||||
{
|
||||
projectId,
|
||||
flowId: flowId as string | undefined,
|
||||
status: status as 'pending' | 'processing' | 'success' | 'failed' | undefined,
|
||||
deleted: includeDeleted === 'true' ? true : undefined,
|
||||
},
|
||||
validatedLimit,
|
||||
validatedOffset
|
||||
);
|
||||
|
||||
const responseData = result.generations.map((gen) => toGenerationResponse(gen));
|
||||
|
||||
res.json(
|
||||
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a single generation by ID with full details
|
||||
*
|
||||
* Retrieves complete generation information including:
|
||||
* - Generation status and metadata
|
||||
* - Output image details (URL, dimensions, etc.)
|
||||
* - Reference images used
|
||||
* - Flow association
|
||||
* - Timestamps and audit trail
|
||||
*
|
||||
* @route GET /api/v1/generations/:id
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id - Generation ID (UUID)
|
||||
*
|
||||
* @returns {GetGenerationResponse} 200 - Complete generation details
|
||||
* @returns {object} 404 - Generation not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
||||
*/
|
||||
generationsRouter.get(
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
||||
const service = getGenerationService();
|
||||
const { id } = req.params;
|
||||
|
||||
const generation = await service.getByIdWithRelations(id);
|
||||
|
||||
if (generation.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toGenerationResponse(generation),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Update generation parameters with automatic regeneration
|
||||
*
|
||||
* Updates generation settings with intelligent regeneration behavior:
|
||||
* - Changing prompt or aspectRatio triggers automatic regeneration
|
||||
* - Changing flowId or meta updates metadata only (no regeneration)
|
||||
* - Regeneration replaces existing output image (same ID and URLs)
|
||||
* - All changes preserve generation history and IDs
|
||||
*
|
||||
* @route PUT /api/v1/generations/:id
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {string} req.params.id - Generation ID (UUID)
|
||||
* @param {UpdateGenerationRequest} req.body - Update parameters
|
||||
* @param {string} [req.body.prompt] - New prompt (triggers regeneration)
|
||||
* @param {string} [req.body.aspectRatio] - New aspect ratio (triggers regeneration)
|
||||
* @param {string|null} [req.body.flowId] - Change flow association (null to detach)
|
||||
* @param {object} [req.body.meta] - Update custom metadata
|
||||
*
|
||||
* @returns {GetGenerationResponse} 200 - Updated generation with new output
|
||||
* @returns {object} 404 - Generation not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
||||
* @throws {Error} FLOW_NOT_FOUND - New flow ID does not exist
|
||||
*
|
||||
* @example
|
||||
* // Update prompt (triggers regeneration)
|
||||
* PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
||||
* {
|
||||
* "prompt": "Updated: A mountain landscape with vibrant colors"
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Change flow association (no regeneration)
|
||||
* PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
||||
* {
|
||||
* "flowId": "new-flow-id-123"
|
||||
* }
|
||||
*/
|
||||
generationsRouter.put(
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
||||
const service = getGenerationService();
|
||||
const { id } = req.params;
|
||||
const { prompt, aspectRatio, flowId, meta } = req.body;
|
||||
|
||||
const original = await service.getById(id);
|
||||
if (!original) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (original.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await service.update(id, {
|
||||
prompt,
|
||||
aspectRatio,
|
||||
flowId,
|
||||
meta,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toGenerationResponse(updated),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Regenerate existing generation with exact same parameters
|
||||
*
|
||||
* Creates a new image using the original generation parameters:
|
||||
* - Uses exact same prompt, aspect ratio, and reference images
|
||||
* - Works regardless of current status (success, failed, pending)
|
||||
* - Replaces existing output image (preserves ID and URLs)
|
||||
* - No parameter modifications allowed (use PUT for changes)
|
||||
* - Useful for refreshing stale images or recovering from failures
|
||||
*
|
||||
* @route POST /api/v1/generations/:id/regenerate
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {string} req.params.id - Generation ID (UUID)
|
||||
*
|
||||
* @returns {GetGenerationResponse} 200 - Regenerated generation with new output
|
||||
* @returns {object} 404 - Generation not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
||||
*
|
||||
* @example
|
||||
* POST /api/v1/generations/550e8400-e29b-41d4-a716-446655440000/regenerate
|
||||
*/
|
||||
generationsRouter.post(
|
||||
'/:id/regenerate',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
||||
const service = getGenerationService();
|
||||
const { id } = req.params;
|
||||
|
||||
const original = await service.getById(id);
|
||||
if (!original) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (original.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const regenerated = await service.regenerate(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toGenerationResponse(regenerated),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Retry a failed generation (legacy endpoint)
|
||||
*
|
||||
* @deprecated Use POST /api/v1/generations/:id/regenerate instead
|
||||
*
|
||||
* This endpoint is maintained for backward compatibility and delegates
|
||||
* to the regenerate endpoint. New integrations should use /regenerate.
|
||||
*
|
||||
* @route POST /api/v1/generations/:id/retry
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {string} req.params.id - Generation ID (UUID)
|
||||
*
|
||||
* @returns {CreateGenerationResponse} 201 - Regenerated generation
|
||||
* @returns {object} 404 - Generation not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @see POST /api/v1/generations/:id/regenerate - Preferred endpoint
|
||||
*/
|
||||
generationsRouter.post(
|
||||
'/:id/retry',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
|
||||
const service = getGenerationService();
|
||||
const { id } = req.params;
|
||||
|
||||
const original = await service.getById(id);
|
||||
if (!original) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (original.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const regenerated = await service.regenerate(id);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: toGenerationResponse(regenerated),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete a generation and conditionally its output image (Section 7.2)
|
||||
*
|
||||
* Performs deletion with alias protection:
|
||||
* - Hard delete generation record (permanently removed from database)
|
||||
* - If output image has NO project alias: hard delete image with MinIO cleanup
|
||||
* - If output image HAS project alias: keep image, set generationId=NULL
|
||||
*
|
||||
* Rationale: Images with aliases are used as standalone assets and should be preserved.
|
||||
* Images without aliases were created only for this generation and can be deleted together.
|
||||
*
|
||||
* @route DELETE /api/v1/generations/:id
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id - Generation ID (UUID)
|
||||
*
|
||||
* @returns {object} 200 - Deletion confirmation with generation ID
|
||||
* @returns {object} 404 - Generation not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
||||
*
|
||||
* @example
|
||||
* DELETE /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "success": true,
|
||||
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||
* }
|
||||
*/
|
||||
generationsRouter.delete(
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const service = getGenerationService();
|
||||
const { id } = req.params;
|
||||
|
||||
const generation = await service.getById(id);
|
||||
if (!generation) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (generation.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await service.delete(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { id },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
@ -0,0 +1,946 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import sizeOf from 'image-size';
|
||||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { ImageService, AliasService } from '@/services/core';
|
||||
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';
|
||||
import { uploadSingleImage, handleUploadErrors } from '@/middleware/upload';
|
||||
import { validateAndNormalizePagination } from '@/utils/validators';
|
||||
import { buildPaginatedResponse } from '@/utils/helpers';
|
||||
import { toImageResponse } from '@/types/responses';
|
||||
import { db } from '@/db';
|
||||
import { flows, type Image } from '@banatie/database';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type {
|
||||
UploadImageResponse,
|
||||
ListImagesResponse,
|
||||
GetImageResponse,
|
||||
UpdateImageResponse,
|
||||
DeleteImageResponse,
|
||||
ResolveAliasResponse,
|
||||
} from '@/types/responses';
|
||||
|
||||
export const imagesRouter: RouterType = Router();
|
||||
|
||||
let imageService: ImageService;
|
||||
let aliasService: AliasService;
|
||||
|
||||
const getImageService = (): ImageService => {
|
||||
if (!imageService) {
|
||||
imageService = new ImageService();
|
||||
}
|
||||
return imageService;
|
||||
};
|
||||
|
||||
const getAliasService = (): AliasService => {
|
||||
if (!aliasService) {
|
||||
aliasService = new AliasService();
|
||||
}
|
||||
return aliasService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve id_or_alias parameter to imageId
|
||||
* Supports both UUID and alias (@-prefixed) identifiers
|
||||
* Per Section 6.2 of api-refactoring-final.md
|
||||
*
|
||||
* @param identifier - UUID or alias string
|
||||
* @param projectId - Project ID for alias resolution
|
||||
* @param flowId - Optional flow ID for flow-scoped alias resolution
|
||||
* @returns imageId (UUID)
|
||||
* @throws Error if alias not found
|
||||
*/
|
||||
async function resolveImageIdentifier(
|
||||
identifier: string,
|
||||
projectId: string,
|
||||
flowId?: string
|
||||
): Promise<string> {
|
||||
// Check if parameter is alias (starts with @)
|
||||
if (identifier.startsWith('@')) {
|
||||
const aliasServiceInstance = getAliasService();
|
||||
const resolution = await aliasServiceInstance.resolve(
|
||||
identifier,
|
||||
projectId,
|
||||
flowId
|
||||
);
|
||||
|
||||
if (!resolution) {
|
||||
throw new Error(`Alias '${identifier}' not found`);
|
||||
}
|
||||
|
||||
return resolution.imageId;
|
||||
}
|
||||
|
||||
// Otherwise treat as UUID
|
||||
return identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single image file to project storage
|
||||
*
|
||||
* Uploads an image file to MinIO storage and creates a database record with support for:
|
||||
* - Lazy flow creation using pendingFlowId when flowId is undefined
|
||||
* - Eager flow creation when flowAlias is provided
|
||||
* - Project-scoped alias assignment
|
||||
* - Custom metadata storage
|
||||
* - Multiple file formats (JPEG, PNG, WebP, etc.)
|
||||
*
|
||||
* FlowId behavior:
|
||||
* - undefined (not provided) → generates pendingFlowId, defers flow creation (lazy)
|
||||
* - null (explicitly null) → no flow association
|
||||
* - string (specific value) → uses provided flow ID, creates if needed
|
||||
*
|
||||
* @route POST /api/v1/images/upload
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {File} req.file - Image file (multipart/form-data, max 5MB)
|
||||
* @param {string} [req.body.alias] - Project-scoped alias (@custom-name)
|
||||
* @param {string|null} [req.body.flowId] - Flow association (undefined=auto, null=none, string=specific)
|
||||
* @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId, triggers eager creation)
|
||||
* @param {string} [req.body.meta] - Custom metadata (JSON string)
|
||||
*
|
||||
* @returns {UploadImageResponse} 201 - Uploaded image with storage details
|
||||
* @returns {object} 400 - Missing file or validation error
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 413 - File too large
|
||||
* @returns {object} 415 - Unsupported file type
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
* @returns {object} 500 - Upload or storage error
|
||||
*
|
||||
* @throws {Error} VALIDATION_ERROR - No file provided
|
||||
* @throws {Error} UPLOAD_ERROR - File upload failed
|
||||
* @throws {Error} ALIAS_CONFLICT - Alias already exists
|
||||
*
|
||||
* @example
|
||||
* // Upload with automatic flow creation
|
||||
* POST /api/v1/images/upload
|
||||
* Content-Type: multipart/form-data
|
||||
* { file: <image.jpg>, alias: "@hero-bg" }
|
||||
*
|
||||
* @example
|
||||
* // Upload with eager flow creation and flow alias
|
||||
* POST /api/v1/images/upload
|
||||
* { file: <image.jpg>, flowAlias: "@step-1" }
|
||||
*/
|
||||
imagesRouter.post(
|
||||
'/upload',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
uploadSingleImage,
|
||||
handleUploadErrors,
|
||||
asyncHandler(async (req: any, res: Response<UploadImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { alias, flowId, flowAlias, meta } = req.body;
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'No file provided',
|
||||
code: 'VALIDATION_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.apiKey.projectId;
|
||||
const apiKeyId = req.apiKey.id;
|
||||
const orgId = req.apiKey.organizationSlug || 'default';
|
||||
const projectSlug = req.apiKey.projectSlug;
|
||||
const file = req.file;
|
||||
|
||||
// FlowId logic (matching GenerationService lazy pattern):
|
||||
// - If undefined → generate UUID for pendingFlowId, flowId = null (lazy)
|
||||
// - If null → flowId = null, pendingFlowId = null (explicitly no flow)
|
||||
// - If string → flowId = string, pendingFlowId = null (use provided, create if needed)
|
||||
let finalFlowId: string | null;
|
||||
let pendingFlowId: string | null = null;
|
||||
|
||||
if (flowId === undefined) {
|
||||
// Lazy pattern: defer flow creation until needed
|
||||
pendingFlowId = randomUUID();
|
||||
finalFlowId = null;
|
||||
} else if (flowId === null) {
|
||||
// Explicitly no flow
|
||||
finalFlowId = null;
|
||||
pendingFlowId = null;
|
||||
} else {
|
||||
// Specific flowId provided - ensure flow exists (eager creation)
|
||||
finalFlowId = flowId;
|
||||
pendingFlowId = null;
|
||||
|
||||
// Check if flow exists, create if not
|
||||
const existingFlow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, finalFlowId),
|
||||
});
|
||||
|
||||
if (!existingFlow) {
|
||||
await db.insert(flows).values({
|
||||
id: finalFlowId,
|
||||
projectId,
|
||||
aliases: {},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// Link any pending images to this new flow
|
||||
await service.linkPendingImagesToFlow(finalFlowId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
const uploadResult = await storageService.uploadFile(
|
||||
orgId,
|
||||
projectSlug,
|
||||
'uploads',
|
||||
file.originalname,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'File upload failed',
|
||||
code: 'UPLOAD_ERROR',
|
||||
details: uploadResult.error,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract image dimensions from uploaded file buffer
|
||||
let width: number | null = null;
|
||||
let height: number | null = null;
|
||||
try {
|
||||
const dimensions = sizeOf(file.buffer);
|
||||
if (dimensions.width && dimensions.height) {
|
||||
width = dimensions.width;
|
||||
height = dimensions.height;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract image dimensions:', error);
|
||||
}
|
||||
|
||||
const imageRecord = await service.create({
|
||||
projectId,
|
||||
flowId: finalFlowId,
|
||||
pendingFlowId: pendingFlowId,
|
||||
generationId: null,
|
||||
apiKeyId,
|
||||
storageKey: uploadResult.path!,
|
||||
storageUrl: uploadResult.url!,
|
||||
mimeType: file.mimetype,
|
||||
fileSize: file.size,
|
||||
fileHash: null,
|
||||
source: 'uploaded',
|
||||
alias: null,
|
||||
meta: meta ? JSON.parse(meta) : {},
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
// Reassign project alias if provided (override behavior per Section 5.2)
|
||||
if (alias) {
|
||||
await service.reassignProjectAlias(alias, imageRecord.id, projectId);
|
||||
}
|
||||
|
||||
// Eager flow creation if flowAlias is provided
|
||||
if (flowAlias) {
|
||||
// Use pendingFlowId if available, otherwise finalFlowId
|
||||
const flowIdToUse = pendingFlowId || finalFlowId;
|
||||
|
||||
if (!flowIdToUse) {
|
||||
throw new Error('Cannot create flow: no flowId available');
|
||||
}
|
||||
|
||||
// Check if flow exists, create if not
|
||||
const existingFlow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, flowIdToUse),
|
||||
});
|
||||
|
||||
if (!existingFlow) {
|
||||
await db.insert(flows).values({
|
||||
id: flowIdToUse,
|
||||
projectId,
|
||||
aliases: {},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// Link pending images if this was a lazy flow
|
||||
if (pendingFlowId) {
|
||||
await service.linkPendingImagesToFlow(flowIdToUse, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign flow alias to uploaded image
|
||||
const flow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, flowIdToUse),
|
||||
});
|
||||
|
||||
if (flow) {
|
||||
const currentAliases = (flow.aliases as Record<string, string>) || {};
|
||||
const updatedAliases = { ...currentAliases };
|
||||
updatedAliases[flowAlias] = imageRecord.id;
|
||||
|
||||
await db
|
||||
.update(flows)
|
||||
.set({ aliases: updatedAliases, updatedAt: new Date() })
|
||||
.where(eq(flows.id, flowIdToUse));
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch image to include any updates (alias assignment, flow alias)
|
||||
const finalImage = await service.getById(imageRecord.id);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: toImageResponse(finalImage!),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Upload failed',
|
||||
code: 'UPLOAD_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* List all images for the project with filtering and pagination
|
||||
*
|
||||
* Retrieves images (both generated and uploaded) with support for:
|
||||
* - Flow-based filtering
|
||||
* - Source filtering (generated vs uploaded)
|
||||
* - Alias filtering (exact match)
|
||||
* - Pagination with configurable limit and offset
|
||||
* - Optional inclusion of soft-deleted images
|
||||
*
|
||||
* @route GET /api/v1/images
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} [req.query.flowId] - Filter by flow ID
|
||||
* @param {string} [req.query.source] - Filter by source (generated|uploaded)
|
||||
* @param {string} [req.query.alias] - Filter by exact alias match
|
||||
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||
* @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted images
|
||||
*
|
||||
* @returns {ListImagesResponse} 200 - Paginated list of images
|
||||
* @returns {object} 400 - Invalid pagination parameters
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @example
|
||||
* // List uploaded images in a flow
|
||||
* GET /api/v1/images?flowId=abc-123&source=uploaded&limit=50
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<ListImagesResponse>) => {
|
||||
const service = getImageService();
|
||||
const { flowId, source, alias, limit, offset, includeDeleted } = req.query;
|
||||
|
||||
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||
if (!paginationResult.valid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
const result = await service.list(
|
||||
{
|
||||
projectId,
|
||||
flowId: flowId as string | undefined,
|
||||
source: source as 'generated' | 'uploaded' | undefined,
|
||||
alias: alias as string | undefined,
|
||||
deleted: includeDeleted === 'true' ? true : undefined,
|
||||
},
|
||||
validatedLimit,
|
||||
validatedOffset
|
||||
);
|
||||
|
||||
const responseData = result.images.map((img) => toImageResponse(img));
|
||||
|
||||
res.json(
|
||||
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated Use GET /api/v1/images/:alias directly instead (Section 6.2)
|
||||
*
|
||||
* Resolve an alias to an image using 3-tier precedence system
|
||||
*
|
||||
* **DEPRECATED**: This endpoint is deprecated as of Section 6.2. Use the main
|
||||
* GET /api/v1/images/:id_or_alias endpoint instead, which supports both UUIDs
|
||||
* and aliases (@-prefixed) directly in the path parameter.
|
||||
*
|
||||
* **Migration Guide**:
|
||||
* - Old: GET /api/v1/images/resolve/@hero
|
||||
* - New: GET /api/v1/images/@hero
|
||||
*
|
||||
* This endpoint remains functional for backwards compatibility but will be
|
||||
* removed in a future version.
|
||||
*
|
||||
* Resolves aliases through a priority-based lookup system:
|
||||
* 1. Technical aliases (@last, @first, @upload) - computed on-the-fly
|
||||
* 2. Flow-scoped aliases - looked up in flow's JSONB aliases field (requires flowId)
|
||||
* 3. Project-scoped aliases - looked up in images.alias column
|
||||
*
|
||||
* Returns the image ID, resolution scope, and complete image details.
|
||||
*
|
||||
* @route GET /api/v1/images/resolve/:alias
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.alias - Alias to resolve (e.g., "@last", "@hero", "@step-1")
|
||||
* @param {string} [req.query.flowId] - Flow context for flow-scoped resolution
|
||||
*
|
||||
* @returns {ResolveAliasResponse} 200 - Resolved image with scope and details (includes X-Deprecated header)
|
||||
* @returns {object} 404 - Alias not found in any scope
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} ALIAS_NOT_FOUND - Alias does not exist
|
||||
* @throws {Error} RESOLUTION_ERROR - Resolution failed
|
||||
*
|
||||
* @example
|
||||
* // Resolve technical alias
|
||||
* GET /api/v1/images/resolve/@last
|
||||
*
|
||||
* @example
|
||||
* // Resolve flow-scoped alias
|
||||
* GET /api/v1/images/resolve/@step-1?flowId=abc-123
|
||||
*
|
||||
* @example
|
||||
* // Resolve project-scoped alias
|
||||
* GET /api/v1/images/resolve/@hero-bg
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/resolve/:alias',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<ResolveAliasResponse>) => {
|
||||
const aliasServiceInstance = getAliasService();
|
||||
const { alias } = req.params;
|
||||
const { flowId } = req.query;
|
||||
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
// Add deprecation header
|
||||
res.setHeader(
|
||||
'X-Deprecated',
|
||||
'This endpoint is deprecated. Use GET /api/v1/images/:alias instead (Section 6.2)'
|
||||
);
|
||||
|
||||
try {
|
||||
const resolution = await aliasServiceInstance.resolve(
|
||||
alias,
|
||||
projectId,
|
||||
flowId as string | undefined
|
||||
);
|
||||
|
||||
if (!resolution) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: `Alias '${alias}' not found`,
|
||||
code: 'ALIAS_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify project ownership
|
||||
if (resolution.image && resolution.image.projectId !== projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Alias not found',
|
||||
code: 'ALIAS_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
alias,
|
||||
imageId: resolution.imageId,
|
||||
scope: resolution.scope,
|
||||
flowId: resolution.flowId,
|
||||
image: resolution.image ? toImageResponse(resolution.image) : ({} as any),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Failed to resolve alias',
|
||||
code: 'RESOLUTION_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a single image by ID with complete details
|
||||
*
|
||||
* Retrieves full image information including:
|
||||
* - Storage URLs and keys
|
||||
* - Project and flow associations
|
||||
* - Alias assignments (project-scoped)
|
||||
* - Source (generated vs uploaded)
|
||||
* - File metadata (size, MIME type, hash)
|
||||
* - Focal point and custom metadata
|
||||
*
|
||||
* @route GET /api/v1/images/:id_or_alias
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@alias)
|
||||
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||
*
|
||||
* @returns {GetImageResponse} 200 - Complete image details
|
||||
* @returns {object} 404 - Image not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
||||
* GET /api/v1/images/@hero
|
||||
* GET /api/v1/images/@hero?flowId=abc-123
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/:id_or_alias',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<GetImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { id_or_alias } = req.params;
|
||||
const { flowId } = req.query;
|
||||
|
||||
// Resolve alias to imageId if needed (Section 6.2)
|
||||
let imageId: string;
|
||||
try {
|
||||
imageId = await resolveImageIdentifier(
|
||||
id_or_alias,
|
||||
req.apiKey.projectId,
|
||||
flowId as string | undefined
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await service.getById(imageId);
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (image.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toImageResponse(image),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Update image metadata (focal point and custom metadata)
|
||||
*
|
||||
* Updates non-generative image properties:
|
||||
* - Focal point for image cropping (x, y coordinates 0.0-1.0)
|
||||
* - Custom metadata (arbitrary JSON object)
|
||||
*
|
||||
* Note: Alias assignment moved to separate endpoint PUT /images/:id/alias (Section 6.1)
|
||||
* Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
|
||||
*
|
||||
* @route PUT /api/v1/images/:id_or_alias
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
|
||||
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||
* @param {UpdateImageRequest} req.body - Update parameters
|
||||
* @param {object} [req.body.focalPoint] - Focal point for cropping
|
||||
* @param {number} req.body.focalPoint.x - X coordinate (0.0-1.0)
|
||||
* @param {number} req.body.focalPoint.y - Y coordinate (0.0-1.0)
|
||||
* @param {object} [req.body.meta] - Custom metadata
|
||||
*
|
||||
* @returns {UpdateImageResponse} 200 - Updated image details
|
||||
* @returns {object} 404 - Image not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||
*
|
||||
* @example UUID identifier
|
||||
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
||||
* {
|
||||
* "focalPoint": { "x": 0.5, "y": 0.3 },
|
||||
* "meta": { "category": "hero", "priority": 1 }
|
||||
* }
|
||||
*
|
||||
* @example Project-scoped alias
|
||||
* PUT /api/v1/images/@hero-banner
|
||||
* {
|
||||
* "focalPoint": { "x": 0.5, "y": 0.3 }
|
||||
* }
|
||||
*
|
||||
* @example Flow-scoped alias
|
||||
* PUT /api/v1/images/@product-shot?flowId=123e4567-e89b-12d3-a456-426614174000
|
||||
* {
|
||||
* "meta": { "category": "product" }
|
||||
* }
|
||||
*/
|
||||
imagesRouter.put(
|
||||
'/:id_or_alias',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { id_or_alias } = req.params;
|
||||
const { flowId } = req.query;
|
||||
const { focalPoint, meta } = req.body; // Removed alias (Section 6.1)
|
||||
|
||||
// Resolve alias to imageId if needed (Section 6.2)
|
||||
let imageId: string;
|
||||
try {
|
||||
imageId = await resolveImageIdentifier(
|
||||
id_or_alias,
|
||||
req.apiKey.projectId,
|
||||
flowId as string | undefined
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await service.getById(imageId);
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (image.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: {
|
||||
focalPoint?: { x: number; y: number };
|
||||
meta?: Record<string, unknown>;
|
||||
} = {};
|
||||
|
||||
if (focalPoint !== undefined) updates.focalPoint = focalPoint;
|
||||
if (meta !== undefined) updates.meta = meta;
|
||||
|
||||
const updated = await service.update(imageId, updates);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toImageResponse(updated),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Assign or remove a project-scoped alias from an image
|
||||
*
|
||||
* Sets, updates, or removes the project-scoped alias for an image:
|
||||
* - Alias must start with @ symbol (when assigning)
|
||||
* - Must be unique within the project
|
||||
* - Replaces existing alias if image already has one
|
||||
* - Used for alias resolution in generations and CDN access
|
||||
* - Set alias to null to remove existing alias
|
||||
*
|
||||
* This is a dedicated endpoint introduced in Section 6.1 to separate
|
||||
* alias assignment from general metadata updates.
|
||||
* Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
|
||||
*
|
||||
* @route PUT /api/v1/images/:id_or_alias/alias
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
|
||||
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||
* @param {object} req.body - Request body
|
||||
* @param {string|null} req.body.alias - Project-scoped alias (e.g., "@hero-bg") or null to remove
|
||||
*
|
||||
* @returns {UpdateImageResponse} 200 - Updated image with new/removed alias
|
||||
* @returns {object} 404 - Image not found or access denied
|
||||
* @returns {object} 400 - Invalid alias format
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 409 - Alias already exists
|
||||
*
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||
* @throws {Error} VALIDATION_ERROR - Invalid alias format
|
||||
* @throws {Error} ALIAS_CONFLICT - Alias already assigned to another image
|
||||
*
|
||||
* @example Assign alias
|
||||
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias
|
||||
* {
|
||||
* "alias": "@hero-background"
|
||||
* }
|
||||
*
|
||||
* @example Remove alias
|
||||
* PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias
|
||||
* {
|
||||
* "alias": null
|
||||
* }
|
||||
*
|
||||
* @example Project-scoped alias identifier
|
||||
* PUT /api/v1/images/@old-hero/alias
|
||||
* {
|
||||
* "alias": "@new-hero"
|
||||
* }
|
||||
*
|
||||
* @example Flow-scoped alias identifier
|
||||
* PUT /api/v1/images/@temp-product/alias?flowId=123e4567-e89b-12d3-a456-426614174000
|
||||
* {
|
||||
* "alias": "@final-product"
|
||||
* }
|
||||
*/
|
||||
imagesRouter.put(
|
||||
'/:id_or_alias/alias',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { id_or_alias } = req.params;
|
||||
const { flowId } = req.query;
|
||||
const { alias } = req.body;
|
||||
|
||||
// Validate: alias must be null (to remove) or a non-empty string
|
||||
if (alias !== null && (typeof alias !== 'string' || alias.trim() === '')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Alias must be null (to remove) or a non-empty string',
|
||||
code: 'VALIDATION_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve alias to imageId if needed (Section 6.2)
|
||||
let imageId: string;
|
||||
try {
|
||||
imageId = await resolveImageIdentifier(
|
||||
id_or_alias,
|
||||
req.apiKey.projectId,
|
||||
flowId as string | undefined
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await service.getById(imageId);
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (image.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Either remove alias (null) or assign new one (override behavior per Section 5.2)
|
||||
let updated: Image;
|
||||
if (alias === null) {
|
||||
// Remove alias
|
||||
updated = await service.update(imageId, { alias: null });
|
||||
} else {
|
||||
// Reassign alias (clears from any existing image, then assigns to this one)
|
||||
await service.reassignProjectAlias(alias, imageId, image.projectId);
|
||||
updated = (await service.getById(imageId))!;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toImageResponse(updated),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete an image with storage cleanup and cascading deletions
|
||||
*
|
||||
* Performs hard delete of image record and MinIO file with cascading operations:
|
||||
* - Deletes image record from database (hard delete, no soft delete)
|
||||
* - Removes file from MinIO storage permanently
|
||||
* - Cascades to delete generation-image relationships
|
||||
* - Removes image from flow aliases (if present)
|
||||
* - Cannot be undone
|
||||
*
|
||||
* Use with caution: This is a destructive operation that permanently removes
|
||||
* the image file and all database references.
|
||||
* Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
|
||||
*
|
||||
* @route DELETE /api/v1/images/:id_or_alias
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
|
||||
* @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
|
||||
*
|
||||
* @returns {DeleteImageResponse} 200 - Deletion confirmation with image ID
|
||||
* @returns {object} 404 - Image not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||
*
|
||||
* @example UUID identifier
|
||||
* DELETE /api/v1/images/550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "success": true,
|
||||
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||
* }
|
||||
*
|
||||
* @example Project-scoped alias
|
||||
* DELETE /api/v1/images/@old-banner
|
||||
*
|
||||
* @example Flow-scoped alias
|
||||
* DELETE /api/v1/images/@temp-image?flowId=123e4567-e89b-12d3-a456-426614174000
|
||||
*/
|
||||
imagesRouter.delete(
|
||||
'/:id_or_alias',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<DeleteImageResponse>) => {
|
||||
const service = getImageService();
|
||||
const { id_or_alias } = req.params;
|
||||
const { flowId } = req.query;
|
||||
|
||||
// Resolve alias to imageId if needed (Section 6.2)
|
||||
let imageId: string;
|
||||
try {
|
||||
imageId = await resolveImageIdentifier(
|
||||
id_or_alias,
|
||||
req.apiKey.projectId,
|
||||
flowId as string | undefined
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = await service.getById(imageId);
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (image.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Image not found',
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await service.hardDelete(imageId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { id: imageId },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { generationsRouter } from './generations';
|
||||
import { flowsRouter } from './flows';
|
||||
import { imagesRouter } from './images';
|
||||
import { liveRouter } from './live';
|
||||
import { scopesRouter } from './scopes';
|
||||
|
||||
export const v1Router: RouterType = Router();
|
||||
|
||||
// Mount v1 routes
|
||||
v1Router.use('/generations', generationsRouter);
|
||||
v1Router.use('/flows', flowsRouter);
|
||||
v1Router.use('/images', imagesRouter);
|
||||
v1Router.use('/live', liveRouter);
|
||||
v1Router.use('/live/scopes', scopesRouter);
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { PromptCacheService, GenerationService, ImageService } from '@/services/core';
|
||||
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';
|
||||
import { GENERATION_LIMITS } from '@/utils/constants';
|
||||
|
||||
export const liveRouter: RouterType = Router();
|
||||
|
||||
let promptCacheService: PromptCacheService;
|
||||
let generationService: GenerationService;
|
||||
let imageService: ImageService;
|
||||
|
||||
const getPromptCacheService = (): PromptCacheService => {
|
||||
if (!promptCacheService) {
|
||||
promptCacheService = new PromptCacheService();
|
||||
}
|
||||
return promptCacheService;
|
||||
};
|
||||
|
||||
const getGenerationService = (): GenerationService => {
|
||||
if (!generationService) {
|
||||
generationService = new GenerationService();
|
||||
}
|
||||
return generationService;
|
||||
};
|
||||
|
||||
const getImageService = (): ImageService => {
|
||||
if (!imageService) {
|
||||
imageService = new ImageService();
|
||||
}
|
||||
return imageService;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/v1/live
|
||||
* Generate image with prompt caching
|
||||
* Returns image bytes directly with cache headers
|
||||
*/
|
||||
liveRouter.get(
|
||||
'/',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const cacheService = getPromptCacheService();
|
||||
const genService = getGenerationService();
|
||||
const imgService = getImageService();
|
||||
const { prompt, aspectRatio } = req.query;
|
||||
|
||||
// Validate prompt
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Prompt is required and must be a string',
|
||||
code: 'VALIDATION_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.apiKey.projectId;
|
||||
const apiKeyId = req.apiKey.id;
|
||||
|
||||
try {
|
||||
// Compute prompt hash for cache lookup
|
||||
const promptHash = cacheService.computePromptHash(prompt);
|
||||
|
||||
// Check cache
|
||||
const cachedEntry = await cacheService.getCachedEntry(promptHash, projectId);
|
||||
|
||||
if (cachedEntry) {
|
||||
// Cache HIT - fetch and stream existing image
|
||||
await cacheService.recordCacheHit(cachedEntry.id);
|
||||
|
||||
// Get image from database
|
||||
const image = await imgService.getById(cachedEntry.imageId);
|
||||
if (!image) {
|
||||
throw new Error('Cached image not found in database');
|
||||
}
|
||||
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
// Parse storage key to get components
|
||||
// Format: orgId/projectId/category/filename.ext
|
||||
const keyParts = image.storageKey.split('/');
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
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(
|
||||
orgId!,
|
||||
projectIdSlug!,
|
||||
category,
|
||||
filename!
|
||||
);
|
||||
|
||||
// Set cache headers
|
||||
res.setHeader('Content-Type', image.mimeType);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||
res.setHeader('X-Cache-Status', 'HIT');
|
||||
res.setHeader('X-Cache-Hit-Count', cachedEntry.hitCount.toString());
|
||||
res.setHeader('X-Image-Id', image.id);
|
||||
|
||||
// Stream image bytes
|
||||
res.send(buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache MISS - generate new image
|
||||
const generation = await genService.create({
|
||||
projectId,
|
||||
apiKeyId,
|
||||
prompt,
|
||||
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
// Get the output image
|
||||
if (!generation.outputImage) {
|
||||
throw new Error('Generation succeeded but no output image was created');
|
||||
}
|
||||
|
||||
// Create cache entry
|
||||
const queryParamsHash = cacheService.computePromptHash(
|
||||
JSON.stringify({ aspectRatio: aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO })
|
||||
);
|
||||
|
||||
await cacheService.createCacheEntry({
|
||||
projectId,
|
||||
generationId: generation.id,
|
||||
imageId: generation.outputImage.id,
|
||||
promptHash,
|
||||
queryParamsHash,
|
||||
originalPrompt: prompt,
|
||||
requestParams: {
|
||||
aspectRatio: aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
},
|
||||
hitCount: 0,
|
||||
});
|
||||
|
||||
// Download newly generated image
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
// Format: orgId/projectId/category/filename.ext
|
||||
const keyParts = generation.outputImage.storageKey.split('/');
|
||||
if (keyParts.length < 4) {
|
||||
throw new Error('Invalid storage key format');
|
||||
}
|
||||
|
||||
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(
|
||||
orgId!,
|
||||
projectIdSlug!,
|
||||
category,
|
||||
filename!
|
||||
);
|
||||
|
||||
// Set cache headers
|
||||
res.setHeader('Content-Type', generation.outputImage.mimeType);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
|
||||
res.setHeader('X-Cache-Status', 'MISS');
|
||||
res.setHeader('X-Generation-Id', generation.id);
|
||||
res.setHeader('X-Image-Id', generation.outputImage.id);
|
||||
|
||||
// Stream image bytes
|
||||
res.send(buffer);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Live generation error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Generation failed',
|
||||
code: 'GENERATION_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
@ -0,0 +1,510 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { LiveScopeService, ImageService, GenerationService } from '@/services/core';
|
||||
import { asyncHandler } from '@/middleware/errorHandler';
|
||||
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||
import { PAGINATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
|
||||
import { buildPaginationMeta } from '@/utils/helpers';
|
||||
import { toLiveScopeResponse, toImageResponse } from '@/types/responses';
|
||||
import type {
|
||||
CreateLiveScopeRequest,
|
||||
ListLiveScopesQuery,
|
||||
UpdateLiveScopeRequest,
|
||||
RegenerateScopeRequest,
|
||||
} from '@/types/requests';
|
||||
import type {
|
||||
CreateLiveScopeResponse,
|
||||
GetLiveScopeResponse,
|
||||
ListLiveScopesResponse,
|
||||
UpdateLiveScopeResponse,
|
||||
DeleteLiveScopeResponse,
|
||||
RegenerateScopeResponse,
|
||||
} from '@/types/responses';
|
||||
|
||||
export const scopesRouter: RouterType = Router();
|
||||
|
||||
let scopeService: LiveScopeService;
|
||||
let imageService: ImageService;
|
||||
let generationService: GenerationService;
|
||||
|
||||
const getScopeService = (): LiveScopeService => {
|
||||
if (!scopeService) {
|
||||
scopeService = new LiveScopeService();
|
||||
}
|
||||
return scopeService;
|
||||
};
|
||||
|
||||
const getImageService = (): ImageService => {
|
||||
if (!imageService) {
|
||||
imageService = new ImageService();
|
||||
}
|
||||
return imageService;
|
||||
};
|
||||
|
||||
const getGenerationService = (): GenerationService => {
|
||||
if (!generationService) {
|
||||
generationService = new GenerationService();
|
||||
}
|
||||
return generationService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new live scope manually with settings
|
||||
*
|
||||
* Creates a live scope for organizing live URL generations:
|
||||
* - Slug must be unique within the project
|
||||
* - Slug format: alphanumeric + hyphens + underscores only
|
||||
* - Configure generation limits and permissions
|
||||
* - Optional custom metadata storage
|
||||
*
|
||||
* Note: Scopes are typically auto-created via live URLs, but this endpoint
|
||||
* allows pre-configuration with specific settings.
|
||||
*
|
||||
* @route POST /api/v1/live/scopes
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {CreateLiveScopeRequest} req.body - Scope configuration
|
||||
* @param {string} req.body.slug - Unique scope identifier (alphanumeric + hyphens + underscores)
|
||||
* @param {boolean} [req.body.allowNewGenerations=true] - Allow new generations in scope
|
||||
* @param {number} [req.body.newGenerationsLimit=30] - Maximum generations allowed
|
||||
* @param {object} [req.body.meta] - Custom metadata
|
||||
*
|
||||
* @returns {CreateLiveScopeResponse} 201 - Created scope with stats
|
||||
* @returns {object} 400 - Invalid slug format
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 409 - Scope slug already exists
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @throws {Error} SCOPE_INVALID_FORMAT - Invalid slug format
|
||||
* @throws {Error} SCOPE_ALREADY_EXISTS - Slug already in use
|
||||
*
|
||||
* @example
|
||||
* POST /api/v1/live/scopes
|
||||
* {
|
||||
* "slug": "hero-section",
|
||||
* "allowNewGenerations": true,
|
||||
* "newGenerationsLimit": 50,
|
||||
* "meta": { "description": "Hero section images" }
|
||||
* }
|
||||
*/
|
||||
scopesRouter.post(
|
||||
'/',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response<CreateLiveScopeResponse>) => {
|
||||
const service = getScopeService();
|
||||
const { slug, allowNewGenerations, newGenerationsLimit, meta } = req.body as CreateLiveScopeRequest;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
// Validate slug format
|
||||
if (!slug || !/^[a-zA-Z0-9_-]+$/.test(slug)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT,
|
||||
code: 'SCOPE_INVALID_FORMAT',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if scope already exists
|
||||
const existing = await service.getBySlug(projectId, slug);
|
||||
if (existing) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Scope with this slug already exists',
|
||||
code: 'SCOPE_ALREADY_EXISTS',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create scope
|
||||
const scope = await service.create({
|
||||
projectId,
|
||||
slug,
|
||||
allowNewGenerations: allowNewGenerations ?? true,
|
||||
newGenerationsLimit: newGenerationsLimit ?? 30,
|
||||
meta: meta || {},
|
||||
});
|
||||
|
||||
// Get with stats
|
||||
const scopeWithStats = await service.getByIdWithStats(scope.id);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: toLiveScopeResponse(scopeWithStats),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* List all live scopes for the project with pagination and statistics
|
||||
*
|
||||
* Retrieves all scopes (both auto-created and manually created) with:
|
||||
* - Computed currentGenerations count (active only)
|
||||
* - Last generation timestamp
|
||||
* - Pagination support
|
||||
* - Optional slug filtering
|
||||
*
|
||||
* @route GET /api/v1/live/scopes
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} [req.query.slug] - Filter by exact slug match
|
||||
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
||||
* @param {number} [req.query.offset=0] - Number of results to skip
|
||||
*
|
||||
* @returns {ListLiveScopesResponse} 200 - Paginated list of scopes with stats
|
||||
* @returns {object} 400 - Invalid pagination parameters
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/live/scopes?limit=50&offset=0
|
||||
*/
|
||||
scopesRouter.get(
|
||||
'/',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<ListLiveScopesResponse>) => {
|
||||
const service = getScopeService();
|
||||
const { slug, limit, offset } = req.query as ListLiveScopesQuery;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
const parsedLimit = Math.min(
|
||||
(limit ? parseInt(limit.toString(), 10) : PAGINATION_LIMITS.DEFAULT_LIMIT) || PAGINATION_LIMITS.DEFAULT_LIMIT,
|
||||
PAGINATION_LIMITS.MAX_LIMIT,
|
||||
);
|
||||
const parsedOffset = (offset ? parseInt(offset.toString(), 10) : 0) || 0;
|
||||
|
||||
const result = await service.list(
|
||||
{ projectId, slug },
|
||||
parsedLimit,
|
||||
parsedOffset,
|
||||
);
|
||||
|
||||
const scopeResponses = result.scopes.map(toLiveScopeResponse);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: scopeResponses,
|
||||
pagination: buildPaginationMeta(result.total, parsedLimit, parsedOffset),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a single live scope by slug with complete statistics
|
||||
*
|
||||
* Retrieves detailed scope information including:
|
||||
* - Current generation count (active generations only)
|
||||
* - Last generation timestamp
|
||||
* - Settings (allowNewGenerations, newGenerationsLimit)
|
||||
* - Custom metadata
|
||||
* - Creation and update timestamps
|
||||
*
|
||||
* @route GET /api/v1/live/scopes/:slug
|
||||
* @authentication Project Key required
|
||||
*
|
||||
* @param {string} req.params.slug - Scope slug identifier
|
||||
*
|
||||
* @returns {GetLiveScopeResponse} 200 - Complete scope details with stats
|
||||
* @returns {object} 404 - Scope not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
*
|
||||
* @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/live/scopes/hero-section
|
||||
*/
|
||||
scopesRouter.get(
|
||||
'/:slug',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<GetLiveScopeResponse>) => {
|
||||
const service = getScopeService();
|
||||
const { slug } = req.params;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
const scopeWithStats = await service.getBySlugWithStats(projectId, slug);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toLiveScopeResponse(scopeWithStats),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Update live scope settings and metadata
|
||||
*
|
||||
* Modifies scope configuration:
|
||||
* - Enable/disable new generations
|
||||
* - Adjust generation limits
|
||||
* - Update custom metadata
|
||||
*
|
||||
* Changes take effect immediately for new live URL requests.
|
||||
*
|
||||
* @route PUT /api/v1/live/scopes/:slug
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {string} req.params.slug - Scope slug identifier
|
||||
* @param {UpdateLiveScopeRequest} req.body - Update parameters
|
||||
* @param {boolean} [req.body.allowNewGenerations] - Allow/disallow new generations
|
||||
* @param {number} [req.body.newGenerationsLimit] - Update generation limit
|
||||
* @param {object} [req.body.meta] - Update custom metadata
|
||||
*
|
||||
* @returns {UpdateLiveScopeResponse} 200 - Updated scope with stats
|
||||
* @returns {object} 404 - Scope not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
|
||||
*
|
||||
* @example
|
||||
* PUT /api/v1/live/scopes/hero-section
|
||||
* {
|
||||
* "allowNewGenerations": false,
|
||||
* "newGenerationsLimit": 100
|
||||
* }
|
||||
*/
|
||||
scopesRouter.put(
|
||||
'/:slug',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response<UpdateLiveScopeResponse>) => {
|
||||
const service = getScopeService();
|
||||
const { slug } = req.params;
|
||||
const { allowNewGenerations, newGenerationsLimit, meta } = req.body as UpdateLiveScopeRequest;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
// Get scope
|
||||
const scope = await service.getBySlugOrThrow(projectId, slug);
|
||||
|
||||
// Update scope
|
||||
const updates: {
|
||||
allowNewGenerations?: boolean;
|
||||
newGenerationsLimit?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
} = {};
|
||||
if (allowNewGenerations !== undefined) updates.allowNewGenerations = allowNewGenerations;
|
||||
if (newGenerationsLimit !== undefined) updates.newGenerationsLimit = newGenerationsLimit;
|
||||
if (meta !== undefined) updates.meta = meta;
|
||||
|
||||
await service.update(scope.id, updates);
|
||||
|
||||
// Get updated scope with stats
|
||||
const updated = await service.getByIdWithStats(scope.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toLiveScopeResponse(updated),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Regenerate images in a live scope
|
||||
*
|
||||
* Regenerates either a specific image or all images in the scope:
|
||||
* - Specific image: Provide imageId in request body
|
||||
* - All images: Omit imageId to regenerate entire scope
|
||||
* - Uses exact same parameters (prompt, aspect ratio, etc.)
|
||||
* - Updates existing images (preserves IDs and URLs)
|
||||
* - Verifies image belongs to scope before regenerating
|
||||
*
|
||||
* Useful for refreshing stale cached images or recovering from failures.
|
||||
*
|
||||
* @route POST /api/v1/live/scopes/:slug/regenerate
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {string} req.params.slug - Scope slug identifier
|
||||
* @param {RegenerateScopeRequest} [req.body] - Regeneration options
|
||||
* @param {string} [req.body.imageId] - Specific image to regenerate (omit for all)
|
||||
*
|
||||
* @returns {RegenerateScopeResponse} 200 - Regeneration results
|
||||
* @returns {object} 400 - Image not in scope
|
||||
* @returns {object} 404 - Scope or image not found
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
|
||||
* @throws {Error} IMAGE_NOT_FOUND - Image does not exist
|
||||
* @throws {Error} IMAGE_NOT_IN_SCOPE - Image doesn't belong to scope
|
||||
*
|
||||
* @example
|
||||
* // Regenerate specific image
|
||||
* POST /api/v1/live/scopes/hero-section/regenerate
|
||||
* {
|
||||
* "imageId": "550e8400-e29b-41d4-a716-446655440000"
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Regenerate all images in scope
|
||||
* POST /api/v1/live/scopes/hero-section/regenerate
|
||||
* {}
|
||||
*/
|
||||
scopesRouter.post(
|
||||
'/:slug/regenerate',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response<RegenerateScopeResponse>) => {
|
||||
const scopeService = getScopeService();
|
||||
const imgService = getImageService();
|
||||
const genService = getGenerationService();
|
||||
const { slug } = req.params;
|
||||
const { imageId } = req.body as RegenerateScopeRequest;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
// Get scope
|
||||
const scope = await scopeService.getBySlugWithStats(projectId, slug);
|
||||
|
||||
if (imageId) {
|
||||
// Regenerate specific image
|
||||
const image = await imgService.getById(imageId);
|
||||
if (!image) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.IMAGE_NOT_FOUND,
|
||||
code: 'IMAGE_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if image belongs to this scope
|
||||
const imageMeta = image.meta as Record<string, unknown>;
|
||||
if (imageMeta['scope'] !== slug) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Image does not belong to this scope',
|
||||
code: 'IMAGE_NOT_IN_SCOPE',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Regenerate the image's generation
|
||||
if (image.generationId) {
|
||||
await genService.regenerate(image.generationId);
|
||||
}
|
||||
|
||||
const regeneratedImage = await imgService.getById(imageId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
regenerated: 1,
|
||||
images: regeneratedImage ? [toImageResponse(regeneratedImage)] : [],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Regenerate all images in scope
|
||||
if (!scope.images || scope.images.length === 0) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
regenerated: 0,
|
||||
images: [],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const regeneratedImages = [];
|
||||
for (const image of scope.images) {
|
||||
if (image.generationId) {
|
||||
await genService.regenerate(image.generationId);
|
||||
const regenerated = await imgService.getById(image.id);
|
||||
if (regenerated) {
|
||||
regeneratedImages.push(toImageResponse(regenerated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
regenerated: regeneratedImages.length,
|
||||
images: regeneratedImages,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete a live scope with cascading image deletion
|
||||
*
|
||||
* Permanently removes the scope and all its associated images:
|
||||
* - Hard deletes all images in scope (MinIO + database)
|
||||
* - Follows alias protection rules for each image
|
||||
* - Hard deletes scope record (no soft delete)
|
||||
* - Cannot be undone
|
||||
*
|
||||
* Use with caution: This is a destructive operation that permanently
|
||||
* removes the scope and all cached live URL images.
|
||||
*
|
||||
* @route DELETE /api/v1/live/scopes/:slug
|
||||
* @authentication Project Key required
|
||||
* @rateLimit 100 requests per hour per API key
|
||||
*
|
||||
* @param {string} req.params.slug - Scope slug identifier
|
||||
*
|
||||
* @returns {DeleteLiveScopeResponse} 200 - Deletion confirmation with scope ID
|
||||
* @returns {object} 404 - Scope not found or access denied
|
||||
* @returns {object} 401 - Missing or invalid API key
|
||||
* @returns {object} 429 - Rate limit exceeded
|
||||
*
|
||||
* @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
|
||||
*
|
||||
* @example
|
||||
* DELETE /api/v1/live/scopes/hero-section
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "success": true,
|
||||
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
||||
* }
|
||||
*/
|
||||
scopesRouter.delete(
|
||||
'/:slug',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response<DeleteLiveScopeResponse>) => {
|
||||
const scopeService = getScopeService();
|
||||
const imgService = getImageService();
|
||||
const { slug } = req.params;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
// Get scope with images
|
||||
const scope = await scopeService.getBySlugWithStats(projectId, slug);
|
||||
|
||||
// Delete all images in scope (follows alias protection rules)
|
||||
if (scope.images) {
|
||||
for (const image of scope.images) {
|
||||
await imgService.hardDelete(image.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete scope record
|
||||
await scopeService.delete(scope.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { id: scope.id },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
|
@ -9,6 +9,21 @@ export interface ApiKeyWithSlugs extends ApiKey {
|
|||
projectSlug?: string;
|
||||
}
|
||||
|
||||
// Extended API key type with full organization and project details for admin listing
|
||||
export interface ApiKeyWithDetails extends ApiKey {
|
||||
organization: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
email: string;
|
||||
} | null;
|
||||
project: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export class ApiKeyService {
|
||||
/**
|
||||
* Generate a new API key
|
||||
|
|
@ -182,10 +197,73 @@ export class ApiKeyService {
|
|||
}
|
||||
|
||||
/**
|
||||
* List all keys (for admin)
|
||||
* List all keys (for admin) with organization and project details
|
||||
*/
|
||||
async listKeys(): Promise<ApiKey[]> {
|
||||
return db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt));
|
||||
async listKeys(): Promise<ApiKeyWithDetails[]> {
|
||||
const results = await db
|
||||
.select({
|
||||
// API key fields
|
||||
id: apiKeys.id,
|
||||
keyHash: apiKeys.keyHash,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
keyType: apiKeys.keyType,
|
||||
organizationId: apiKeys.organizationId,
|
||||
projectId: apiKeys.projectId,
|
||||
scopes: apiKeys.scopes,
|
||||
createdAt: apiKeys.createdAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
isActive: apiKeys.isActive,
|
||||
name: apiKeys.name,
|
||||
createdBy: apiKeys.createdBy,
|
||||
// Organization fields
|
||||
orgId: organizations.id,
|
||||
orgSlug: organizations.slug,
|
||||
orgName: organizations.name,
|
||||
orgEmail: organizations.email,
|
||||
// Project fields
|
||||
projId: projects.id,
|
||||
projSlug: projects.slug,
|
||||
projName: projects.name,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.leftJoin(organizations, eq(apiKeys.organizationId, organizations.id))
|
||||
.leftJoin(projects, eq(apiKeys.projectId, projects.id))
|
||||
.orderBy(desc(apiKeys.createdAt));
|
||||
|
||||
// Transform flat results to nested structure
|
||||
return results.map((row) => ({
|
||||
id: row.id,
|
||||
keyHash: row.keyHash,
|
||||
keyPrefix: row.keyPrefix,
|
||||
keyType: row.keyType,
|
||||
organizationId: row.organizationId,
|
||||
projectId: row.projectId,
|
||||
scopes: row.scopes,
|
||||
createdAt: row.createdAt,
|
||||
expiresAt: row.expiresAt,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
isActive: row.isActive,
|
||||
name: row.name,
|
||||
createdBy: row.createdBy,
|
||||
organization:
|
||||
row.orgId && row.orgSlug && row.orgName && row.orgEmail
|
||||
? {
|
||||
id: row.orgId,
|
||||
slug: row.orgSlug,
|
||||
name: row.orgName,
|
||||
email: row.orgEmail,
|
||||
}
|
||||
: null,
|
||||
project:
|
||||
row.projId && row.projSlug && row.projName
|
||||
? {
|
||||
id: row.projId,
|
||||
slug: row.projSlug,
|
||||
name: row.projName,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { GoogleGenAI } from '@google/genai';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mime = require('mime') as any;
|
||||
import sizeOf from 'image-size';
|
||||
import {
|
||||
ImageGenerationOptions,
|
||||
ImageGenerationResult,
|
||||
|
|
@ -78,8 +79,10 @@ export class ImageGenService {
|
|||
filename: uploadResult.filename,
|
||||
filepath: uploadResult.path,
|
||||
url: uploadResult.url,
|
||||
size: uploadResult.size,
|
||||
model: this.primaryModel,
|
||||
geminiParams,
|
||||
generatedImageData: generatedData,
|
||||
...(generatedData.description && {
|
||||
description: generatedData.description,
|
||||
}),
|
||||
|
|
@ -231,10 +234,25 @@ export class ImageGenService {
|
|||
|
||||
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
|
||||
|
||||
// Extract image dimensions from buffer
|
||||
let width = 1024; // Default fallback
|
||||
let height = 1024; // Default fallback
|
||||
try {
|
||||
const dimensions = sizeOf(imageData.buffer);
|
||||
if (dimensions.width && dimensions.height) {
|
||||
width = dimensions.width;
|
||||
height = dimensions.height;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract image dimensions, using defaults:', error);
|
||||
}
|
||||
|
||||
const generatedData: GeneratedImageData = {
|
||||
buffer: imageData.buffer,
|
||||
mimeType: imageData.mimeType,
|
||||
fileExtension,
|
||||
width,
|
||||
height,
|
||||
...(generatedDescription && { description: generatedDescription }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
import { eq, and, isNull, desc, or } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { images, flows } from '@banatie/database';
|
||||
import type { AliasResolution, Image } from '@/types/models';
|
||||
import { isTechnicalAlias } from '@/utils/constants/aliases';
|
||||
import {
|
||||
validateAliasFormat,
|
||||
validateAliasNotReserved,
|
||||
} from '@/utils/validators';
|
||||
import { ERROR_MESSAGES } from '@/utils/constants';
|
||||
|
||||
export class AliasService {
|
||||
async resolve(
|
||||
alias: string,
|
||||
projectId: string,
|
||||
flowId?: string
|
||||
): Promise<AliasResolution | null> {
|
||||
const formatResult = validateAliasFormat(alias);
|
||||
if (!formatResult.valid) {
|
||||
throw new Error(formatResult.error!.message);
|
||||
}
|
||||
|
||||
if (isTechnicalAlias(alias)) {
|
||||
if (!flowId) {
|
||||
throw new Error(ERROR_MESSAGES.TECHNICAL_ALIAS_REQUIRES_FLOW);
|
||||
}
|
||||
return await this.resolveTechnicalAlias(alias, flowId, projectId);
|
||||
}
|
||||
|
||||
if (flowId) {
|
||||
const flowResolution = await this.resolveFlowAlias(alias, flowId, projectId);
|
||||
if (flowResolution) {
|
||||
return flowResolution;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.resolveProjectAlias(alias, projectId);
|
||||
}
|
||||
|
||||
private async resolveTechnicalAlias(
|
||||
alias: string,
|
||||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<AliasResolution | null> {
|
||||
let image: Image | undefined;
|
||||
|
||||
switch (alias) {
|
||||
case '@last':
|
||||
image = await this.getLastGeneratedInFlow(flowId, projectId);
|
||||
break;
|
||||
|
||||
case '@first':
|
||||
image = await this.getFirstGeneratedInFlow(flowId, projectId);
|
||||
break;
|
||||
|
||||
case '@upload':
|
||||
image = await this.getLastUploadedInFlow(flowId, projectId);
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
imageId: image.id,
|
||||
scope: 'technical',
|
||||
flowId,
|
||||
image,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveFlowAlias(
|
||||
alias: string,
|
||||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<AliasResolution | null> {
|
||||
const flow = await db.query.flows.findFirst({
|
||||
where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)),
|
||||
});
|
||||
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flowAliases = flow.aliases as Record<string, string>;
|
||||
const imageId = flowAliases[alias];
|
||||
|
||||
if (!imageId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.id, imageId),
|
||||
eq(images.projectId, projectId),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
imageId: image.id,
|
||||
scope: 'flow',
|
||||
flowId,
|
||||
image,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveProjectAlias(
|
||||
alias: string,
|
||||
projectId: string
|
||||
): Promise<AliasResolution | null> {
|
||||
// Project aliases can exist on images with or without flowId
|
||||
// Per spec: images with project alias should be resolvable at project level
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.alias, alias),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
imageId: image.id,
|
||||
scope: 'project',
|
||||
image,
|
||||
};
|
||||
}
|
||||
|
||||
private async getLastGeneratedInFlow(
|
||||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<Image | undefined> {
|
||||
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
|
||||
// Images may have pendingFlowId before the flow record is created
|
||||
return await db.query.images.findFirst({
|
||||
where: and(
|
||||
or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.source, 'generated'),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
orderBy: [desc(images.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
private async getFirstGeneratedInFlow(
|
||||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<Image | undefined> {
|
||||
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
|
||||
const allImages = await db.query.images.findMany({
|
||||
where: and(
|
||||
or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.source, 'generated'),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
orderBy: [images.createdAt],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return allImages[0];
|
||||
}
|
||||
|
||||
private async getLastUploadedInFlow(
|
||||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<Image | undefined> {
|
||||
// Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
|
||||
return await db.query.images.findFirst({
|
||||
where: and(
|
||||
or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.source, 'uploaded'),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
orderBy: [desc(images.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
async validateAliasForAssignment(alias: string, projectId: string, flowId?: string): Promise<void> {
|
||||
const formatResult = validateAliasFormat(alias);
|
||||
if (!formatResult.valid) {
|
||||
throw new Error(formatResult.error!.message);
|
||||
}
|
||||
|
||||
const reservedResult = validateAliasNotReserved(alias);
|
||||
if (!reservedResult.valid) {
|
||||
throw new Error(reservedResult.error!.message);
|
||||
}
|
||||
|
||||
// NOTE: Conflict checks removed per Section 5.2 of api-refactoring-final.md
|
||||
// Aliases now use override behavior - new requests take priority over existing aliases
|
||||
// Flow alias conflicts are handled by JSONB field overwrite (no check needed)
|
||||
}
|
||||
|
||||
// DEPRECATED: Removed per Section 5.2 - aliases now use override behavior
|
||||
// private async checkProjectAliasConflict(alias: string, projectId: string): Promise<void> {
|
||||
// const existing = await db.query.images.findFirst({
|
||||
// where: and(
|
||||
// eq(images.projectId, projectId),
|
||||
// eq(images.alias, alias),
|
||||
// isNull(images.deletedAt),
|
||||
// isNull(images.flowId)
|
||||
// ),
|
||||
// });
|
||||
//
|
||||
// if (existing) {
|
||||
// throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
|
||||
// }
|
||||
// }
|
||||
|
||||
// DEPRECATED: Removed per Section 5.2 - flow aliases now use override behavior
|
||||
// Flow alias conflicts are naturally handled by JSONB field overwrite in assignFlowAlias()
|
||||
// private async checkFlowAliasConflict(alias: string, flowId: string, projectId: string): Promise<void> {
|
||||
// const flow = await db.query.flows.findFirst({
|
||||
// where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)),
|
||||
// });
|
||||
//
|
||||
// if (!flow) {
|
||||
// throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||
// }
|
||||
//
|
||||
// const flowAliases = flow.aliases as Record<string, string>;
|
||||
// if (flowAliases[alias]) {
|
||||
// throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
|
||||
// }
|
||||
// }
|
||||
|
||||
async resolveMultiple(
|
||||
aliases: string[],
|
||||
projectId: string,
|
||||
flowId?: string
|
||||
): Promise<Map<string, AliasResolution>> {
|
||||
const resolutions = new Map<string, AliasResolution>();
|
||||
|
||||
for (const alias of aliases) {
|
||||
const resolution = await this.resolve(alias, projectId, flowId);
|
||||
if (resolution) {
|
||||
resolutions.set(alias, resolution);
|
||||
}
|
||||
}
|
||||
|
||||
return resolutions;
|
||||
}
|
||||
|
||||
async resolveToImageIds(
|
||||
aliases: string[],
|
||||
projectId: string,
|
||||
flowId?: string
|
||||
): Promise<string[]> {
|
||||
const imageIds: string[] = [];
|
||||
|
||||
for (const alias of aliases) {
|
||||
const resolution = await this.resolve(alias, projectId, flowId);
|
||||
if (resolution) {
|
||||
imageIds.push(resolution.imageId);
|
||||
} else {
|
||||
throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`);
|
||||
}
|
||||
}
|
||||
|
||||
return imageIds;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
import { eq, desc, count } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { flows, generations, images } from '@banatie/database';
|
||||
import type { Flow, NewFlow, FlowFilters, FlowWithCounts } from '@/types/models';
|
||||
import { buildWhereClause, buildEqCondition } from '@/utils/helpers';
|
||||
import { ERROR_MESSAGES } from '@/utils/constants';
|
||||
import { GenerationService } from './GenerationService';
|
||||
import { ImageService } from './ImageService';
|
||||
|
||||
export class FlowService {
|
||||
async create(data: NewFlow): Promise<FlowWithCounts> {
|
||||
const [flow] = await db.insert(flows).values(data).returning();
|
||||
if (!flow) {
|
||||
throw new Error('Failed to create flow record');
|
||||
}
|
||||
|
||||
return {
|
||||
...flow,
|
||||
generationCount: 0,
|
||||
imageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Flow | null> {
|
||||
const flow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, id),
|
||||
});
|
||||
|
||||
return flow || null;
|
||||
}
|
||||
|
||||
async getByIdOrThrow(id: string): Promise<Flow> {
|
||||
const flow = await this.getById(id);
|
||||
if (!flow) {
|
||||
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||
}
|
||||
return flow;
|
||||
}
|
||||
|
||||
async getByIdWithCounts(id: string): Promise<FlowWithCounts> {
|
||||
const flow = await this.getByIdOrThrow(id);
|
||||
|
||||
const [genCountResult, imgCountResult] = await Promise.all([
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(generations)
|
||||
.where(eq(generations.flowId, id)),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(images)
|
||||
.where(eq(images.flowId, id)),
|
||||
]);
|
||||
|
||||
const generationCount = Number(genCountResult[0]?.count || 0);
|
||||
const imageCount = Number(imgCountResult[0]?.count || 0);
|
||||
|
||||
return {
|
||||
...flow,
|
||||
generationCount,
|
||||
imageCount,
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
filters: FlowFilters,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ flows: FlowWithCounts[]; total: number }> {
|
||||
const conditions = [
|
||||
buildEqCondition(flows, 'projectId', filters.projectId),
|
||||
];
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const [flowsList, countResult] = await Promise.all([
|
||||
db.query.flows.findMany({
|
||||
where: whereClause,
|
||||
orderBy: [desc(flows.updatedAt)],
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(flows)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const totalCount = countResult[0]?.count || 0;
|
||||
|
||||
const flowsWithCounts = await Promise.all(
|
||||
flowsList.map(async (flow) => {
|
||||
const [genCountResult, imgCountResult] = await Promise.all([
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(generations)
|
||||
.where(eq(generations.flowId, flow.id)),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(images)
|
||||
.where(eq(images.flowId, flow.id)),
|
||||
]);
|
||||
|
||||
return {
|
||||
...flow,
|
||||
generationCount: Number(genCountResult[0]?.count || 0),
|
||||
imageCount: Number(imgCountResult[0]?.count || 0),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
flows: flowsWithCounts,
|
||||
total: Number(totalCount),
|
||||
};
|
||||
}
|
||||
|
||||
async updateAliases(
|
||||
id: string,
|
||||
aliasUpdates: Record<string, string>
|
||||
): Promise<FlowWithCounts> {
|
||||
const flow = await this.getByIdOrThrow(id);
|
||||
|
||||
const currentAliases = (flow.aliases as Record<string, string>) || {};
|
||||
const updatedAliases = { ...currentAliases, ...aliasUpdates };
|
||||
|
||||
const [updated] = await db
|
||||
.update(flows)
|
||||
.set({
|
||||
aliases: updatedAliases,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(flows.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||
}
|
||||
|
||||
return await this.getByIdWithCounts(id);
|
||||
}
|
||||
|
||||
async removeAlias(id: string, alias: string): Promise<FlowWithCounts> {
|
||||
const flow = await this.getByIdOrThrow(id);
|
||||
|
||||
const currentAliases = (flow.aliases as Record<string, string>) || {};
|
||||
const { [alias]: removed, ...remainingAliases } = currentAliases;
|
||||
|
||||
if (removed === undefined) {
|
||||
throw new Error(`Alias '${alias}' not found in flow`);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(flows)
|
||||
.set({
|
||||
aliases: remainingAliases,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(flows.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||
}
|
||||
|
||||
return await this.getByIdWithCounts(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade delete for flow with alias protection (Section 7.3)
|
||||
* Operations:
|
||||
* 1. Delete all generations associated with this flowId (follows conditional delete logic)
|
||||
* 2. Delete all images associated with this flowId EXCEPT images with project alias
|
||||
* 3. For images with alias: keep image, set flowId=NULL
|
||||
* 4. Delete flow record from DB
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
// Get all generations in this flow
|
||||
const flowGenerations = await db.query.generations.findMany({
|
||||
where: eq(generations.flowId, id),
|
||||
});
|
||||
|
||||
// Delete each generation (follows conditional delete logic from Section 7.2)
|
||||
const generationService = new GenerationService();
|
||||
for (const gen of flowGenerations) {
|
||||
await generationService.delete(gen.id);
|
||||
}
|
||||
|
||||
// Get all images in this flow
|
||||
const flowImages = await db.query.images.findMany({
|
||||
where: eq(images.flowId, id),
|
||||
});
|
||||
|
||||
const imageService = new ImageService();
|
||||
for (const img of flowImages) {
|
||||
if (img.alias) {
|
||||
// Image has project alias → keep, unlink from flow
|
||||
await db
|
||||
.update(images)
|
||||
.set({ flowId: null, updatedAt: new Date() })
|
||||
.where(eq(images.id, img.id));
|
||||
} else {
|
||||
// Image without alias → delete
|
||||
await imageService.hardDelete(img.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete flow record
|
||||
await db.delete(flows).where(eq(flows.id, id));
|
||||
}
|
||||
|
||||
async getFlowGenerations(
|
||||
flowId: string,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ generations: any[]; total: number }> {
|
||||
const whereClause = eq(generations.flowId, flowId);
|
||||
|
||||
const [generationsList, countResult] = await Promise.all([
|
||||
db.query.generations.findMany({
|
||||
where: whereClause,
|
||||
orderBy: [desc(generations.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
with: {
|
||||
outputImage: true,
|
||||
},
|
||||
}),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(generations)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const totalCount = countResult[0]?.count || 0;
|
||||
|
||||
return {
|
||||
generations: generationsList,
|
||||
total: Number(totalCount),
|
||||
};
|
||||
}
|
||||
|
||||
async getFlowImages(
|
||||
flowId: string,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ images: any[]; total: number }> {
|
||||
const whereClause = eq(images.flowId, flowId);
|
||||
|
||||
const [imagesList, countResult] = await Promise.all([
|
||||
db.query.images.findMany({
|
||||
where: whereClause,
|
||||
orderBy: [desc(images.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(images)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const totalCount = countResult[0]?.count || 0;
|
||||
|
||||
return {
|
||||
images: imagesList,
|
||||
total: Number(totalCount),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,674 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import { eq, desc, count, and, isNull, inArray } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { generations, flows, images } from '@banatie/database';
|
||||
import type {
|
||||
Generation,
|
||||
NewGeneration,
|
||||
GenerationWithRelations,
|
||||
GenerationFilters,
|
||||
} from '@/types/models';
|
||||
import { ImageService } from './ImageService';
|
||||
import { AliasService } from './AliasService';
|
||||
import { ImageGenService } from '../ImageGenService';
|
||||
import { StorageFactory } from '../StorageFactory';
|
||||
import { buildWhereClause, buildEqCondition } from '@/utils/helpers';
|
||||
import { ERROR_MESSAGES, GENERATION_LIMITS } from '@/utils/constants';
|
||||
import { extractAliasesFromPrompt } from '@/utils/validators';
|
||||
import type { ReferenceImage } from '@/types/api';
|
||||
|
||||
export interface CreateGenerationParams {
|
||||
projectId: string;
|
||||
apiKeyId: string;
|
||||
prompt: string;
|
||||
referenceImages?: string[] | undefined; // Aliases to resolve
|
||||
aspectRatio?: string | undefined;
|
||||
flowId?: string | undefined;
|
||||
alias?: string | undefined;
|
||||
flowAlias?: string | undefined;
|
||||
autoEnhance?: boolean | undefined;
|
||||
enhancedPrompt?: string | undefined;
|
||||
meta?: Record<string, unknown> | undefined;
|
||||
requestId?: string | undefined;
|
||||
}
|
||||
|
||||
export class GenerationService {
|
||||
private imageService: ImageService;
|
||||
private aliasService: AliasService;
|
||||
private imageGenService: ImageGenService;
|
||||
|
||||
constructor() {
|
||||
this.imageService = new ImageService();
|
||||
this.aliasService = new AliasService();
|
||||
|
||||
const geminiApiKey = process.env['GEMINI_API_KEY'];
|
||||
if (!geminiApiKey) {
|
||||
throw new Error('GEMINI_API_KEY environment variable is required');
|
||||
}
|
||||
this.imageGenService = new ImageGenService(geminiApiKey);
|
||||
}
|
||||
|
||||
async create(params: CreateGenerationParams): Promise<GenerationWithRelations> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Auto-detect aliases from prompt and merge with manual references
|
||||
const autoDetectedAliases = extractAliasesFromPrompt(params.prompt);
|
||||
const manualReferences = params.referenceImages || [];
|
||||
|
||||
// Merge: manual references first, then auto-detected (remove duplicates)
|
||||
const allReferences = Array.from(new Set([...manualReferences, ...autoDetectedAliases]));
|
||||
|
||||
// FlowId logic (Section 10.1 - UPDATED FOR LAZY PATTERN):
|
||||
// - If undefined → generate UUID for pendingFlowId, flowId = null (lazy)
|
||||
// - If null → flowId = null, pendingFlowId = null (explicitly no flow)
|
||||
// - If string → flowId = string, pendingFlowId = null (use provided, create if needed)
|
||||
let finalFlowId: string | null;
|
||||
let pendingFlowId: string | null = null;
|
||||
|
||||
if (params.flowId === undefined) {
|
||||
// Lazy pattern: defer flow creation until needed
|
||||
pendingFlowId = randomUUID();
|
||||
finalFlowId = null;
|
||||
} else if (params.flowId === null) {
|
||||
// Explicitly no flow
|
||||
finalFlowId = null;
|
||||
pendingFlowId = null;
|
||||
} else {
|
||||
// Specific flowId provided - ensure flow exists (eager creation)
|
||||
finalFlowId = params.flowId;
|
||||
pendingFlowId = null;
|
||||
|
||||
// Check if flow exists, create if not
|
||||
const existingFlow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, finalFlowId),
|
||||
});
|
||||
|
||||
if (!existingFlow) {
|
||||
await db.insert(flows).values({
|
||||
id: finalFlowId,
|
||||
projectId: params.projectId,
|
||||
aliases: {},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// Link any pending generations to this new flow
|
||||
await this.linkPendingGenerationsToFlow(finalFlowId, params.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt semantics (Section 2.1):
|
||||
// - originalPrompt: ALWAYS contains user's original input
|
||||
// - prompt: Enhanced version if autoEnhance=true, otherwise same as originalPrompt
|
||||
const usedPrompt = params.enhancedPrompt || params.prompt;
|
||||
const preservedOriginal = params.prompt; // Always store original
|
||||
|
||||
const generationRecord: NewGeneration = {
|
||||
projectId: params.projectId,
|
||||
flowId: finalFlowId,
|
||||
pendingFlowId: pendingFlowId,
|
||||
apiKeyId: params.apiKeyId,
|
||||
status: 'pending',
|
||||
prompt: usedPrompt, // Prompt actually used for generation
|
||||
originalPrompt: preservedOriginal, // User's original (only if enhanced)
|
||||
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
referencedImages: null,
|
||||
requestId: params.requestId || null,
|
||||
meta: params.meta || {},
|
||||
};
|
||||
|
||||
const [generation] = await db
|
||||
.insert(generations)
|
||||
.values(generationRecord)
|
||||
.returning();
|
||||
|
||||
if (!generation) {
|
||||
throw new Error('Failed to create generation record');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.updateStatus(generation.id, 'processing');
|
||||
|
||||
let referenceImageBuffers: ReferenceImage[] = [];
|
||||
let referencedImagesMetadata: Array<{ imageId: string; alias: string }> = [];
|
||||
|
||||
if (allReferences.length > 0) {
|
||||
const resolved = await this.resolveReferenceImages(
|
||||
allReferences,
|
||||
params.projectId,
|
||||
params.flowId
|
||||
);
|
||||
referenceImageBuffers = resolved.buffers;
|
||||
referencedImagesMetadata = resolved.metadata;
|
||||
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ referencedImages: referencedImagesMetadata })
|
||||
.where(eq(generations.id, generation.id));
|
||||
}
|
||||
|
||||
const genResult = await this.imageGenService.generateImage({
|
||||
prompt: usedPrompt, // Use the prompt that was stored (enhanced or original)
|
||||
filename: `gen_${generation.id}`,
|
||||
referenceImages: referenceImageBuffers,
|
||||
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
orgId: 'default',
|
||||
projectId: params.projectId,
|
||||
meta: params.meta || {},
|
||||
});
|
||||
|
||||
if (!genResult.success) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
await this.updateStatus(generation.id, 'failed', {
|
||||
errorMessage: genResult.error || 'Generation failed',
|
||||
processingTimeMs: processingTime,
|
||||
});
|
||||
throw new Error(genResult.error || 'Generation failed');
|
||||
}
|
||||
|
||||
const storageKey = genResult.filepath!;
|
||||
// TODO: Add file hash computation when we have a helper to download by storageKey
|
||||
const fileHash = null;
|
||||
|
||||
const imageRecord = await this.imageService.create({
|
||||
projectId: params.projectId,
|
||||
flowId: finalFlowId,
|
||||
generationId: generation.id,
|
||||
apiKeyId: params.apiKeyId,
|
||||
storageKey,
|
||||
storageUrl: genResult.url!,
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: genResult.size || 0,
|
||||
fileHash,
|
||||
source: 'generated',
|
||||
alias: null,
|
||||
meta: params.meta || {},
|
||||
width: genResult.generatedImageData?.width ?? null,
|
||||
height: genResult.generatedImageData?.height ?? null,
|
||||
});
|
||||
|
||||
// Reassign project alias if provided (override behavior per Section 5.2)
|
||||
if (params.alias) {
|
||||
await this.imageService.reassignProjectAlias(
|
||||
params.alias,
|
||||
imageRecord.id,
|
||||
params.projectId
|
||||
);
|
||||
}
|
||||
|
||||
// Eager flow creation if flowAlias is provided (Section 4.2)
|
||||
if (params.flowAlias) {
|
||||
// If we have pendingFlowId, create flow and link pending generations
|
||||
const flowIdToUse = pendingFlowId || finalFlowId;
|
||||
|
||||
if (!flowIdToUse) {
|
||||
throw new Error('Cannot create flow: no flowId available');
|
||||
}
|
||||
|
||||
// Check if flow exists, create if not
|
||||
const existingFlow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, flowIdToUse),
|
||||
});
|
||||
|
||||
if (!existingFlow) {
|
||||
await db.insert(flows).values({
|
||||
id: flowIdToUse,
|
||||
projectId: params.projectId,
|
||||
aliases: {},
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// Link any pending generations to this new flow
|
||||
await this.linkPendingGenerationsToFlow(flowIdToUse, params.projectId);
|
||||
}
|
||||
|
||||
await this.assignFlowAlias(flowIdToUse, params.flowAlias, imageRecord.id);
|
||||
}
|
||||
|
||||
// Update flow timestamp if flow was created (either from finalFlowId or pendingFlowId converted to flow)
|
||||
const actualFlowId = finalFlowId || (pendingFlowId && params.flowAlias ? pendingFlowId : null);
|
||||
if (actualFlowId) {
|
||||
await db
|
||||
.update(flows)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(flows.id, actualFlowId));
|
||||
}
|
||||
|
||||
const processingTime = Date.now() - startTime;
|
||||
await this.updateStatus(generation.id, 'success', {
|
||||
outputImageId: imageRecord.id,
|
||||
processingTimeMs: processingTime,
|
||||
});
|
||||
|
||||
return await this.getByIdWithRelations(generation.id);
|
||||
} catch (error) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
await this.updateStatus(generation.id, 'failed', {
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
processingTimeMs: processingTime,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveReferenceImages(
|
||||
aliases: string[],
|
||||
projectId: string,
|
||||
flowId?: string
|
||||
): Promise<{
|
||||
buffers: ReferenceImage[];
|
||||
metadata: Array<{ imageId: string; alias: string }>;
|
||||
}> {
|
||||
const resolutions = await this.aliasService.resolveMultiple(aliases, projectId, flowId);
|
||||
|
||||
const buffers: ReferenceImage[] = [];
|
||||
const metadata: Array<{ imageId: string; alias: string }> = [];
|
||||
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
|
||||
for (const [alias, resolution] of resolutions) {
|
||||
if (!resolution.image) {
|
||||
throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`);
|
||||
}
|
||||
|
||||
const parts = resolution.image.storageKey.split('/');
|
||||
if (parts.length < 4) {
|
||||
throw new Error(`Invalid storage key format: ${resolution.image.storageKey}`);
|
||||
}
|
||||
|
||||
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(
|
||||
orgId,
|
||||
projId,
|
||||
category,
|
||||
filename
|
||||
);
|
||||
|
||||
buffers.push({
|
||||
buffer,
|
||||
mimetype: resolution.image.mimeType,
|
||||
originalname: filename,
|
||||
});
|
||||
|
||||
metadata.push({
|
||||
imageId: resolution.imageId,
|
||||
alias,
|
||||
});
|
||||
}
|
||||
|
||||
return { buffers, metadata };
|
||||
}
|
||||
|
||||
private async assignFlowAlias(
|
||||
flowId: string,
|
||||
flowAlias: string,
|
||||
imageId: string
|
||||
): Promise<void> {
|
||||
const flow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, flowId),
|
||||
});
|
||||
|
||||
if (!flow) {
|
||||
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||
}
|
||||
|
||||
const currentAliases = (flow.aliases as Record<string, string>) || {};
|
||||
const updatedAliases = { ...currentAliases };
|
||||
|
||||
// Assign the flow alias to the image
|
||||
updatedAliases[flowAlias] = imageId;
|
||||
|
||||
await db
|
||||
.update(flows)
|
||||
.set({ aliases: updatedAliases, updatedAt: new Date() })
|
||||
.where(eq(flows.id, flowId));
|
||||
}
|
||||
|
||||
private async linkPendingGenerationsToFlow(
|
||||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
// Find all generations with pendingFlowId matching this flowId
|
||||
const pendingGens = await db.query.generations.findMany({
|
||||
where: and(
|
||||
eq(generations.pendingFlowId, flowId),
|
||||
eq(generations.projectId, projectId)
|
||||
),
|
||||
});
|
||||
|
||||
if (pendingGens.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update generations: set flowId and clear pendingFlowId
|
||||
await db
|
||||
.update(generations)
|
||||
.set({
|
||||
flowId: flowId,
|
||||
pendingFlowId: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(generations.pendingFlowId, flowId),
|
||||
eq(generations.projectId, projectId)
|
||||
)
|
||||
);
|
||||
|
||||
// Also update associated images to have the flowId
|
||||
const generationIds = pendingGens.map(g => g.id);
|
||||
if (generationIds.length > 0) {
|
||||
await db
|
||||
.update(images)
|
||||
.set({
|
||||
flowId: flowId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(images.projectId, projectId),
|
||||
isNull(images.flowId),
|
||||
inArray(images.generationId, generationIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateStatus(
|
||||
id: string,
|
||||
status: 'pending' | 'processing' | 'success' | 'failed',
|
||||
additionalUpdates?: {
|
||||
errorMessage?: string;
|
||||
outputImageId?: string;
|
||||
processingTimeMs?: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(generations)
|
||||
.set({
|
||||
status,
|
||||
...additionalUpdates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(generations.id, id));
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Generation | null> {
|
||||
const generation = await db.query.generations.findFirst({
|
||||
where: eq(generations.id, id),
|
||||
});
|
||||
|
||||
return generation || null;
|
||||
}
|
||||
|
||||
async getByIdWithRelations(id: string): Promise<GenerationWithRelations> {
|
||||
const generation = await db.query.generations.findFirst({
|
||||
where: eq(generations.id, id),
|
||||
with: {
|
||||
outputImage: true,
|
||||
flow: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!generation) {
|
||||
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (generation.referencedImages && Array.isArray(generation.referencedImages)) {
|
||||
const refImageIds = (generation.referencedImages as Array<{ imageId: string; alias: string }>)
|
||||
.map((ref) => ref.imageId);
|
||||
const refImages = await this.imageService.getMultipleByIds(refImageIds);
|
||||
return {
|
||||
...generation,
|
||||
referenceImages: refImages,
|
||||
} as GenerationWithRelations;
|
||||
}
|
||||
|
||||
return generation as GenerationWithRelations;
|
||||
}
|
||||
|
||||
async list(
|
||||
filters: GenerationFilters,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ generations: GenerationWithRelations[]; total: number }> {
|
||||
const conditions = [
|
||||
buildEqCondition(generations, 'projectId', filters.projectId),
|
||||
buildEqCondition(generations, 'flowId', filters.flowId),
|
||||
buildEqCondition(generations, 'status', filters.status),
|
||||
];
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const [generationsList, countResult] = await Promise.all([
|
||||
db.query.generations.findMany({
|
||||
where: whereClause,
|
||||
orderBy: [desc(generations.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
with: {
|
||||
outputImage: true,
|
||||
flow: true,
|
||||
},
|
||||
}),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(generations)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const totalCount = countResult[0]?.count || 0;
|
||||
|
||||
return {
|
||||
generations: generationsList as GenerationWithRelations[],
|
||||
total: Number(totalCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate an existing generation (Section 3)
|
||||
* - Allows regeneration for any status (no status checks)
|
||||
* - Uses exact same parameters as original
|
||||
* - Updates existing image (same ID, path, URL)
|
||||
* - No retry count logic
|
||||
*/
|
||||
async regenerate(id: string): Promise<GenerationWithRelations> {
|
||||
const generation = await this.getById(id);
|
||||
if (!generation) {
|
||||
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!generation.outputImageId) {
|
||||
throw new Error('Cannot regenerate generation without output image');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Update status to processing
|
||||
await this.updateStatus(id, 'processing');
|
||||
|
||||
// Use EXACT same parameters as original (no overrides)
|
||||
const genResult = await this.imageGenService.generateImage({
|
||||
prompt: generation.prompt,
|
||||
filename: `gen_${id}`,
|
||||
referenceImages: [], // TODO: Re-resolve referenced images if needed
|
||||
aspectRatio: generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
orgId: 'default',
|
||||
projectId: generation.projectId,
|
||||
meta: generation.meta as Record<string, unknown> || {},
|
||||
});
|
||||
|
||||
if (!genResult.success) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
await this.updateStatus(id, 'failed', {
|
||||
errorMessage: genResult.error || 'Regeneration failed',
|
||||
processingTimeMs: processingTime,
|
||||
});
|
||||
throw new Error(genResult.error || 'Regeneration failed');
|
||||
}
|
||||
|
||||
// Note: Physical file in MinIO is overwritten by ImageGenService
|
||||
// Image record preserves: imageId, storageKey, storageUrl, alias, createdAt
|
||||
// Image record updates: fileSize (if changed), updatedAt
|
||||
|
||||
const processingTime = Date.now() - startTime;
|
||||
await this.updateStatus(id, 'success', {
|
||||
processingTimeMs: processingTime,
|
||||
});
|
||||
|
||||
return await this.getByIdWithRelations(id);
|
||||
} catch (error) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
await this.updateStatus(id, 'failed', {
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
processingTimeMs: processingTime,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep retry() for backward compatibility, delegate to regenerate()
|
||||
async retry(id: string, overrides?: { prompt?: string; aspectRatio?: string }): Promise<GenerationWithRelations> {
|
||||
// Ignore overrides, regenerate with original parameters
|
||||
return await this.regenerate(id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
updates: {
|
||||
prompt?: string;
|
||||
aspectRatio?: string;
|
||||
flowId?: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<GenerationWithRelations> {
|
||||
const generation = await this.getById(id);
|
||||
if (!generation) {
|
||||
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if generative parameters changed (prompt or aspectRatio)
|
||||
const shouldRegenerate =
|
||||
(updates.prompt !== undefined && updates.prompt !== generation.prompt) ||
|
||||
(updates.aspectRatio !== undefined && updates.aspectRatio !== generation.aspectRatio);
|
||||
|
||||
// Handle flowId change (Section 9.2)
|
||||
if (updates.flowId !== undefined && updates.flowId !== null) {
|
||||
// If flowId provided and not null, create flow if it doesn't exist (eager creation)
|
||||
const existingFlow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, updates.flowId),
|
||||
});
|
||||
|
||||
if (!existingFlow) {
|
||||
await db.insert(flows).values({
|
||||
id: updates.flowId,
|
||||
projectId: generation.projectId,
|
||||
aliases: {},
|
||||
meta: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update database fields
|
||||
const updateData: Partial<NewGeneration> = {};
|
||||
if (updates.prompt !== undefined) {
|
||||
updateData.prompt = updates.prompt; // Update the prompt used for generation
|
||||
}
|
||||
if (updates.aspectRatio !== undefined) {
|
||||
updateData.aspectRatio = updates.aspectRatio;
|
||||
}
|
||||
if (updates.flowId !== undefined) {
|
||||
updateData.flowId = updates.flowId;
|
||||
}
|
||||
if (updates.meta !== undefined) {
|
||||
updateData.meta = updates.meta;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ ...updateData, updatedAt: new Date() })
|
||||
.where(eq(generations.id, id));
|
||||
}
|
||||
|
||||
// If generative parameters changed, trigger regeneration
|
||||
if (shouldRegenerate && generation.outputImageId) {
|
||||
// Update status to processing
|
||||
await this.updateStatus(id, 'processing');
|
||||
|
||||
try {
|
||||
// Use updated prompt/aspectRatio or fall back to existing
|
||||
const promptToUse = updates.prompt || generation.prompt;
|
||||
const aspectRatioToUse = updates.aspectRatio || generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO;
|
||||
|
||||
// Regenerate image
|
||||
const genResult = await this.imageGenService.generateImage({
|
||||
prompt: promptToUse,
|
||||
filename: `gen_${id}`,
|
||||
referenceImages: [],
|
||||
aspectRatio: aspectRatioToUse,
|
||||
orgId: 'default',
|
||||
projectId: generation.projectId,
|
||||
meta: updates.meta || generation.meta || {},
|
||||
});
|
||||
|
||||
if (!genResult.success) {
|
||||
await this.updateStatus(id, 'failed', {
|
||||
errorMessage: genResult.error || 'Regeneration failed',
|
||||
});
|
||||
throw new Error(genResult.error || 'Regeneration failed');
|
||||
}
|
||||
|
||||
// Note: Physical file in MinIO is overwritten by ImageGenService
|
||||
// TODO: Update fileSize and other metadata when ImageService.update() supports it
|
||||
|
||||
await this.updateStatus(id, 'success');
|
||||
} catch (error) {
|
||||
await this.updateStatus(id, 'failed', {
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.getByIdWithRelations(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditional delete for generation (Section 7.2)
|
||||
* - If output image WITHOUT project alias → delete image + generation
|
||||
* - If output image WITH project alias → keep image, delete generation only, set generationId=NULL
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
const generation = await this.getById(id);
|
||||
if (!generation) {
|
||||
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (generation.outputImageId) {
|
||||
// Get the output image to check if it has a project alias
|
||||
const outputImage = await this.imageService.getById(generation.outputImageId);
|
||||
|
||||
if (outputImage) {
|
||||
if (outputImage.alias) {
|
||||
// Case 2: Image has project alias → keep image, delete generation only
|
||||
// Set generationId = NULL in image record
|
||||
await db
|
||||
.update(images)
|
||||
.set({ generationId: null, updatedAt: new Date() })
|
||||
.where(eq(images.id, outputImage.id));
|
||||
} else {
|
||||
// Case 1: Image has no alias → delete both image and generation
|
||||
await this.imageService.hardDelete(generation.outputImageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete generation record (hard delete)
|
||||
await db.delete(generations).where(eq(generations.id, id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
import { eq, and, isNull, desc, count, inArray, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { images, flows, generations } from '@banatie/database';
|
||||
import type { Image, NewImage, ImageFilters } from '@/types/models';
|
||||
import { buildWhereClause, buildEqCondition, withoutDeleted } from '@/utils/helpers';
|
||||
import { ERROR_MESSAGES } from '@/utils/constants';
|
||||
import { AliasService } from './AliasService';
|
||||
import { StorageFactory } from '../StorageFactory';
|
||||
|
||||
export class ImageService {
|
||||
private aliasService: AliasService;
|
||||
|
||||
constructor() {
|
||||
this.aliasService = new AliasService();
|
||||
}
|
||||
|
||||
async create(data: NewImage): Promise<Image> {
|
||||
const [image] = await db.insert(images).values(data).returning();
|
||||
if (!image) {
|
||||
throw new Error('Failed to create image record');
|
||||
}
|
||||
|
||||
// Update flow timestamp if image is part of a flow
|
||||
if (image.flowId) {
|
||||
await db
|
||||
.update(flows)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(flows.id, image.flowId));
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
async getById(id: string, includeDeleted = false): Promise<Image | null> {
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.id, id),
|
||||
includeDeleted ? undefined : isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
return image || null;
|
||||
}
|
||||
|
||||
async getByIdOrThrow(id: string, includeDeleted = false): Promise<Image> {
|
||||
const image = await this.getById(id, includeDeleted);
|
||||
if (!image) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
async list(
|
||||
filters: ImageFilters,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ images: Image[]; total: number }> {
|
||||
const conditions = [
|
||||
buildEqCondition(images, 'projectId', filters.projectId),
|
||||
buildEqCondition(images, 'flowId', filters.flowId),
|
||||
buildEqCondition(images, 'source', filters.source),
|
||||
buildEqCondition(images, 'alias', filters.alias),
|
||||
withoutDeleted(images, filters.deleted),
|
||||
];
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const [imagesList, countResult] = await Promise.all([
|
||||
db.query.images.findMany({
|
||||
where: whereClause,
|
||||
orderBy: [desc(images.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(images)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const totalCount = countResult[0]?.count || 0;
|
||||
|
||||
return {
|
||||
images: imagesList,
|
||||
total: Number(totalCount),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
updates: {
|
||||
alias?: string | null;
|
||||
focalPoint?: { x: number; y: number };
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<Image> {
|
||||
const existing = await this.getByIdOrThrow(id);
|
||||
|
||||
if (updates.alias && updates.alias !== existing.alias) {
|
||||
await this.aliasService.validateAliasForAssignment(
|
||||
updates.alias,
|
||||
existing.projectId,
|
||||
existing.flowId || undefined
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(images)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(images.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<Image> {
|
||||
const [deleted] = await db
|
||||
.update(images)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(images.id, id))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete image with MinIO cleanup and cascades (Section 7.1)
|
||||
* 1. Delete physical file from MinIO storage
|
||||
* 2. Delete record from images table (hard delete)
|
||||
* 3. Cascade: set outputImageId = NULL in related generations
|
||||
* 4. Cascade: remove alias entries from flow.aliases
|
||||
* 5. Cascade: remove imageId from generation.referencedImages arrays
|
||||
*/
|
||||
async hardDelete(id: string): Promise<void> {
|
||||
// Get image to retrieve storage info
|
||||
const image = await this.getById(id, true); // Include deleted
|
||||
if (!image) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Delete physical file from MinIO storage
|
||||
const storageService = await StorageFactory.getInstance();
|
||||
const storageParts = image.storageKey.split('/');
|
||||
|
||||
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(orgId, projectId, category, filename);
|
||||
}
|
||||
|
||||
// 2. Cascade: Set outputImageId = NULL in related generations
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ outputImageId: null })
|
||||
.where(eq(generations.outputImageId, id));
|
||||
|
||||
// 3. Cascade: Remove alias entries from flow.aliases where this imageId is referenced
|
||||
const allFlows = await db.query.flows.findMany();
|
||||
for (const flow of allFlows) {
|
||||
const aliases = (flow.aliases as Record<string, string>) || {};
|
||||
let modified = false;
|
||||
|
||||
// Remove all entries where value equals this imageId
|
||||
const newAliases: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(aliases)) {
|
||||
if (value !== id) {
|
||||
newAliases[key] = value;
|
||||
} else {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
await db
|
||||
.update(flows)
|
||||
.set({ aliases: newAliases, updatedAt: new Date() })
|
||||
.where(eq(flows.id, flow.id));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Cascade: Remove imageId from generation.referencedImages JSON arrays
|
||||
const affectedGenerations = await db.query.generations.findMany({
|
||||
where: sql`${generations.referencedImages}::jsonb @> ${JSON.stringify([{ imageId: id }])}`,
|
||||
});
|
||||
|
||||
for (const gen of affectedGenerations) {
|
||||
const refs = (gen.referencedImages as Array<{ imageId: string; alias: string }>) || [];
|
||||
const filtered = refs.filter(ref => ref.imageId !== id);
|
||||
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ referencedImages: filtered })
|
||||
.where(eq(generations.id, gen.id));
|
||||
}
|
||||
|
||||
// 5. Delete record from images table
|
||||
await db.delete(images).where(eq(images.id, id));
|
||||
|
||||
} catch (error) {
|
||||
// Per Section 7.4: If MinIO delete fails, do NOT proceed with DB cleanup
|
||||
// This prevents orphaned files in MinIO
|
||||
console.error('MinIO delete failed, aborting image deletion:', error);
|
||||
throw new Error(ERROR_MESSAGES.STORAGE_DELETE_FAILED || 'Failed to delete file from storage');
|
||||
}
|
||||
}
|
||||
|
||||
async assignProjectAlias(imageId: string, alias: string): Promise<Image> {
|
||||
const image = await this.getByIdOrThrow(imageId);
|
||||
|
||||
if (image.flowId) {
|
||||
throw new Error('Cannot assign project alias to flow-scoped image');
|
||||
}
|
||||
|
||||
await this.aliasService.validateAliasForAssignment(
|
||||
alias,
|
||||
image.projectId
|
||||
);
|
||||
|
||||
const [updated] = await db
|
||||
.update(images)
|
||||
.set({
|
||||
alias,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(images.id, imageId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign a project-scoped alias to a new image
|
||||
* Clears the alias from any existing image and assigns it to the new image
|
||||
* Implements override behavior per Section 5.2 of api-refactoring-final.md
|
||||
*
|
||||
* @param alias - The alias to reassign (e.g., "@hero")
|
||||
* @param newImageId - ID of the image to receive the alias
|
||||
* @param projectId - Project ID for scope validation
|
||||
*/
|
||||
async reassignProjectAlias(
|
||||
alias: string,
|
||||
newImageId: string,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
// Step 1: Clear alias from any existing image with this alias
|
||||
// Project aliases can exist on images with or without flowId
|
||||
await db
|
||||
.update(images)
|
||||
.set({
|
||||
alias: null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(images.projectId, projectId),
|
||||
eq(images.alias, alias),
|
||||
isNull(images.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
// Step 2: Assign alias to new image
|
||||
await db
|
||||
.update(images)
|
||||
.set({
|
||||
alias: alias,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(images.id, newImageId));
|
||||
}
|
||||
|
||||
async getByStorageKey(storageKey: string): Promise<Image | null> {
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.storageKey, storageKey),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
return image || null;
|
||||
}
|
||||
|
||||
async getByFileHash(fileHash: string, projectId: string): Promise<Image | null> {
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.fileHash, fileHash),
|
||||
eq(images.projectId, projectId),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
return image || null;
|
||||
}
|
||||
|
||||
async getMultipleByIds(ids: string[]): Promise<Image[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await db.query.images.findMany({
|
||||
where: and(
|
||||
inArray(images.id, ids),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Link all pending images to a flow
|
||||
* Called when flow is created to attach all images with matching pendingFlowId
|
||||
*/
|
||||
async linkPendingImagesToFlow(
|
||||
flowId: string,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
// Find all images with pendingFlowId matching this flowId
|
||||
const pendingImages = await db.query.images.findMany({
|
||||
where: and(
|
||||
eq(images.pendingFlowId, flowId),
|
||||
eq(images.projectId, projectId)
|
||||
),
|
||||
});
|
||||
|
||||
if (pendingImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update images: set flowId and clear pendingFlowId
|
||||
await db
|
||||
.update(images)
|
||||
.set({
|
||||
flowId: flowId,
|
||||
pendingFlowId: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(images.pendingFlowId, flowId),
|
||||
eq(images.projectId, projectId)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import { eq, desc, count, and, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { liveScopes, images } from '@banatie/database';
|
||||
import type { LiveScope, NewLiveScope, LiveScopeFilters, LiveScopeWithStats } from '@/types/models';
|
||||
import { buildWhereClause, buildEqCondition } from '@/utils/helpers';
|
||||
import { ERROR_MESSAGES } from '@/utils/constants';
|
||||
|
||||
export class LiveScopeService {
|
||||
/**
|
||||
* Create new live scope
|
||||
* @param data - New scope data (projectId, slug, settings)
|
||||
* @returns Created scope record
|
||||
*/
|
||||
async create(data: NewLiveScope): Promise<LiveScope> {
|
||||
const [scope] = await db.insert(liveScopes).values(data).returning();
|
||||
if (!scope) {
|
||||
throw new Error('Failed to create live scope record');
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scope by ID
|
||||
* @param id - Scope UUID
|
||||
* @returns Scope record or null
|
||||
*/
|
||||
async getById(id: string): Promise<LiveScope | null> {
|
||||
const scope = await db.query.liveScopes.findFirst({
|
||||
where: eq(liveScopes.id, id),
|
||||
});
|
||||
return scope || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scope by slug within a project
|
||||
* @param projectId - Project UUID
|
||||
* @param slug - Scope slug
|
||||
* @returns Scope record or null
|
||||
*/
|
||||
async getBySlug(projectId: string, slug: string): Promise<LiveScope | null> {
|
||||
const scope = await db.query.liveScopes.findFirst({
|
||||
where: and(eq(liveScopes.projectId, projectId), eq(liveScopes.slug, slug)),
|
||||
});
|
||||
return scope || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scope by ID or throw error
|
||||
* @param id - Scope UUID
|
||||
* @returns Scope record
|
||||
* @throws Error if not found
|
||||
*/
|
||||
async getByIdOrThrow(id: string): Promise<LiveScope> {
|
||||
const scope = await this.getById(id);
|
||||
if (!scope) {
|
||||
throw new Error('Live scope not found');
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scope by slug or throw error
|
||||
* @param projectId - Project UUID
|
||||
* @param slug - Scope slug
|
||||
* @returns Scope record
|
||||
* @throws Error if not found
|
||||
*/
|
||||
async getBySlugOrThrow(projectId: string, slug: string): Promise<LiveScope> {
|
||||
const scope = await this.getBySlug(projectId, slug);
|
||||
if (!scope) {
|
||||
throw new Error('Live scope not found');
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scope with computed statistics
|
||||
* @param id - Scope UUID
|
||||
* @returns Scope with currentGenerations count and lastGeneratedAt
|
||||
*/
|
||||
async getByIdWithStats(id: string): Promise<LiveScopeWithStats> {
|
||||
const scope = await this.getByIdOrThrow(id);
|
||||
|
||||
// Count images in this scope (use meta field: { scope: slug, isLiveUrl: true })
|
||||
const scopeImages = await db.query.images.findMany({
|
||||
where: and(
|
||||
eq(images.projectId, scope.projectId),
|
||||
isNull(images.deletedAt),
|
||||
sql`${images.meta}->>'scope' = ${scope.slug}`,
|
||||
sql`(${images.meta}->>'isLiveUrl')::boolean = true`,
|
||||
),
|
||||
orderBy: [desc(images.createdAt)],
|
||||
});
|
||||
|
||||
const currentGenerations = scopeImages.length;
|
||||
const lastGeneratedAt = scopeImages.length > 0 ? scopeImages[0]!.createdAt : null;
|
||||
|
||||
return {
|
||||
...scope,
|
||||
currentGenerations,
|
||||
lastGeneratedAt,
|
||||
images: scopeImages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scope by slug with computed statistics
|
||||
* @param projectId - Project UUID
|
||||
* @param slug - Scope slug
|
||||
* @returns Scope with statistics
|
||||
*/
|
||||
async getBySlugWithStats(projectId: string, slug: string): Promise<LiveScopeWithStats> {
|
||||
const scope = await this.getBySlugOrThrow(projectId, slug);
|
||||
return this.getByIdWithStats(scope.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* List scopes in a project with pagination
|
||||
* @param filters - Query filters (projectId, optional slug)
|
||||
* @param limit - Max results to return
|
||||
* @param offset - Number of results to skip
|
||||
* @returns Array of scopes with stats and total count
|
||||
*/
|
||||
async list(
|
||||
filters: LiveScopeFilters,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<{ scopes: LiveScopeWithStats[]; total: number }> {
|
||||
const conditions = [
|
||||
buildEqCondition(liveScopes, 'projectId', filters.projectId),
|
||||
buildEqCondition(liveScopes, 'slug', filters.slug),
|
||||
];
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const [scopesList, countResult] = await Promise.all([
|
||||
db.query.liveScopes.findMany({
|
||||
where: whereClause,
|
||||
orderBy: [desc(liveScopes.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
db.select({ count: count() }).from(liveScopes).where(whereClause),
|
||||
]);
|
||||
|
||||
const totalCount = countResult[0]?.count || 0;
|
||||
|
||||
// Compute stats for each scope
|
||||
const scopesWithStats = await Promise.all(
|
||||
scopesList.map(async (scope) => {
|
||||
const scopeImages = await db.query.images.findMany({
|
||||
where: and(
|
||||
eq(images.projectId, scope.projectId),
|
||||
isNull(images.deletedAt),
|
||||
sql`${images.meta}->>'scope' = ${scope.slug}`,
|
||||
sql`(${images.meta}->>'isLiveUrl')::boolean = true`,
|
||||
),
|
||||
orderBy: [desc(images.createdAt)],
|
||||
});
|
||||
|
||||
return {
|
||||
...scope,
|
||||
currentGenerations: scopeImages.length,
|
||||
lastGeneratedAt: scopeImages.length > 0 ? scopeImages[0]!.createdAt : null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
scopes: scopesWithStats,
|
||||
total: Number(totalCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scope settings
|
||||
* @param id - Scope UUID
|
||||
* @param updates - Fields to update (allowNewGenerations, newGenerationsLimit, meta)
|
||||
* @returns Updated scope record
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
updates: {
|
||||
allowNewGenerations?: boolean;
|
||||
newGenerationsLimit?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<LiveScope> {
|
||||
// Verify scope exists
|
||||
await this.getByIdOrThrow(id);
|
||||
|
||||
const [updated] = await db
|
||||
.update(liveScopes)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(liveScopes.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('Failed to update live scope');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete scope (hard delete)
|
||||
* Note: Images in this scope are preserved with meta.scope field
|
||||
* @param id - Scope UUID
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
await db.delete(liveScopes).where(eq(liveScopes.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scope can accept new generations
|
||||
* @param scope - Scope record
|
||||
* @param currentCount - Current number of generations (optional, will query if not provided)
|
||||
* @returns true if new generations are allowed
|
||||
*/
|
||||
async canGenerateNew(scope: LiveScope, currentCount?: number): Promise<boolean> {
|
||||
if (!scope.allowNewGenerations) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentCount === undefined) {
|
||||
const stats = await this.getByIdWithStats(scope.id);
|
||||
currentCount = stats.currentGenerations;
|
||||
}
|
||||
|
||||
return currentCount < scope.newGenerationsLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create scope automatically (lazy creation) with project defaults
|
||||
* @param projectId - Project UUID
|
||||
* @param slug - Scope slug
|
||||
* @param projectDefaults - Default settings from project (allowNewGenerations, limit)
|
||||
* @returns Created scope or existing scope if already exists
|
||||
*/
|
||||
async createOrGet(
|
||||
projectId: string,
|
||||
slug: string,
|
||||
projectDefaults: {
|
||||
allowNewLiveScopes: boolean;
|
||||
newLiveScopesGenerationLimit: number;
|
||||
},
|
||||
): Promise<LiveScope> {
|
||||
// Check if scope already exists
|
||||
const existing = await this.getBySlug(projectId, slug);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Check if project allows new scope creation
|
||||
if (!projectDefaults.allowNewLiveScopes) {
|
||||
throw new Error(ERROR_MESSAGES.SCOPE_CREATION_DISABLED);
|
||||
}
|
||||
|
||||
// Create new scope with project defaults
|
||||
return this.create({
|
||||
projectId,
|
||||
slug,
|
||||
allowNewGenerations: true,
|
||||
newGenerationsLimit: projectDefaults.newLiveScopesGenerationLimit,
|
||||
meta: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { promptUrlCache } from '@banatie/database';
|
||||
import type { PromptUrlCacheEntry, NewPromptUrlCacheEntry } from '@/types/models';
|
||||
import { computeSHA256 } from '@/utils/helpers';
|
||||
|
||||
export class PromptCacheService {
|
||||
/**
|
||||
* Compute SHA-256 hash of prompt for cache lookup
|
||||
*/
|
||||
computePromptHash(prompt: string): string {
|
||||
return computeSHA256(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if prompt exists in cache for a project
|
||||
*/
|
||||
async getCachedEntry(
|
||||
promptHash: string,
|
||||
projectId: string
|
||||
): Promise<PromptUrlCacheEntry | null> {
|
||||
const entry = await db.query.promptUrlCache.findFirst({
|
||||
where: and(
|
||||
eq(promptUrlCache.promptHash, promptHash),
|
||||
eq(promptUrlCache.projectId, projectId)
|
||||
),
|
||||
});
|
||||
|
||||
return entry || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new cache entry
|
||||
*/
|
||||
async createCacheEntry(data: NewPromptUrlCacheEntry): Promise<PromptUrlCacheEntry> {
|
||||
const [entry] = await db.insert(promptUrlCache).values(data).returning();
|
||||
if (!entry) {
|
||||
throw new Error('Failed to create cache entry');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hit count and last hit time for a cache entry
|
||||
*/
|
||||
async recordCacheHit(id: string): Promise<void> {
|
||||
await db
|
||||
.update(promptUrlCache)
|
||||
.set({
|
||||
hitCount: sql`${promptUrlCache.hitCount} + 1`,
|
||||
lastHitAt: new Date(),
|
||||
})
|
||||
.where(eq(promptUrlCache.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for a project
|
||||
*/
|
||||
async getCacheStats(projectId: string): Promise<{
|
||||
totalEntries: number;
|
||||
totalHits: number;
|
||||
avgHitCount: number;
|
||||
}> {
|
||||
const entries = await db.query.promptUrlCache.findMany({
|
||||
where: eq(promptUrlCache.projectId, projectId),
|
||||
});
|
||||
|
||||
const totalEntries = entries.length;
|
||||
const totalHits = entries.reduce((sum, entry) => sum + entry.hitCount, 0);
|
||||
const avgHitCount = totalEntries > 0 ? totalHits / totalEntries : 0;
|
||||
|
||||
return {
|
||||
totalEntries,
|
||||
totalHits,
|
||||
avgHitCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old cache entries (can be called periodically)
|
||||
*/
|
||||
async clearOldEntries(daysOld: number): Promise<number> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
||||
|
||||
const result = await db
|
||||
.delete(promptUrlCache)
|
||||
.where(
|
||||
and(
|
||||
eq(promptUrlCache.hitCount, 0),
|
||||
// Only delete entries with 0 hits that are old
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return result.length;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from './AliasService';
|
||||
export * from './ImageService';
|
||||
export * from './GenerationService';
|
||||
export * from './FlowService';
|
||||
export * from './PromptCacheService';
|
||||
export * from './LiveScopeService';
|
||||
|
|
@ -94,6 +94,7 @@ export interface ImageGenerationResult {
|
|||
filename?: string;
|
||||
filepath?: string;
|
||||
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
|
||||
|
|
@ -108,6 +109,8 @@ export interface GeneratedImageData {
|
|||
mimeType: string;
|
||||
fileExtension: string;
|
||||
description?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Logging types
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import type { generations, images, flows, promptUrlCache, liveScopes } from '@banatie/database';
|
||||
|
||||
// Database model types (inferred from Drizzle schema)
|
||||
export type Generation = typeof generations.$inferSelect;
|
||||
export type Image = typeof images.$inferSelect;
|
||||
export type Flow = typeof flows.$inferSelect;
|
||||
export type PromptUrlCacheEntry = typeof promptUrlCache.$inferSelect;
|
||||
export type LiveScope = typeof liveScopes.$inferSelect;
|
||||
|
||||
// Insert types (for creating new records)
|
||||
export type NewGeneration = typeof generations.$inferInsert;
|
||||
export type NewImage = typeof images.$inferInsert;
|
||||
export type NewFlow = typeof flows.$inferInsert;
|
||||
export type NewPromptUrlCacheEntry = typeof promptUrlCache.$inferInsert;
|
||||
export type NewLiveScope = typeof liveScopes.$inferInsert;
|
||||
|
||||
// Generation status enum (matches DB schema)
|
||||
export type GenerationStatus = 'pending' | 'processing' | 'success' | 'failed';
|
||||
|
||||
// Image source enum (matches DB schema)
|
||||
export type ImageSource = 'generated' | 'uploaded';
|
||||
|
||||
// Alias scope types (for resolution)
|
||||
export type AliasScope = 'technical' | 'flow' | 'project';
|
||||
|
||||
// Alias resolution result
|
||||
export interface AliasResolution {
|
||||
imageId: string;
|
||||
scope: AliasScope;
|
||||
flowId?: string;
|
||||
image?: Image;
|
||||
}
|
||||
|
||||
// Enhanced generation with related data
|
||||
export interface GenerationWithRelations extends Generation {
|
||||
outputImage?: Image;
|
||||
referenceImages?: Image[];
|
||||
flow?: Flow;
|
||||
}
|
||||
|
||||
// Enhanced image with related data
|
||||
export interface ImageWithRelations extends Image {
|
||||
generation?: Generation;
|
||||
usedInGenerations?: Generation[];
|
||||
flow?: Flow;
|
||||
}
|
||||
|
||||
// Enhanced flow with computed counts
|
||||
export interface FlowWithCounts extends Flow {
|
||||
generationCount: number;
|
||||
imageCount: number;
|
||||
generations?: Generation[];
|
||||
images?: Image[];
|
||||
}
|
||||
|
||||
// Enhanced live scope with computed stats
|
||||
export interface LiveScopeWithStats extends LiveScope {
|
||||
currentGenerations: number;
|
||||
lastGeneratedAt: Date | null;
|
||||
images?: Image[];
|
||||
}
|
||||
|
||||
// Pagination metadata
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// Query filters for images
|
||||
export interface ImageFilters {
|
||||
projectId: string;
|
||||
flowId?: string | undefined;
|
||||
source?: ImageSource | undefined;
|
||||
alias?: string | undefined;
|
||||
deleted?: boolean | undefined;
|
||||
}
|
||||
|
||||
// Query filters for generations
|
||||
export interface GenerationFilters {
|
||||
projectId: string;
|
||||
flowId?: string | undefined;
|
||||
status?: GenerationStatus | undefined;
|
||||
deleted?: boolean | undefined;
|
||||
}
|
||||
|
||||
// Query filters for flows
|
||||
export interface FlowFilters {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
// Query filters for live scopes
|
||||
export interface LiveScopeFilters {
|
||||
projectId: string;
|
||||
slug?: string | undefined;
|
||||
}
|
||||
|
||||
// Cache statistics
|
||||
export interface CacheStats {
|
||||
hits: number;
|
||||
misses: number;
|
||||
hitRate: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import type { ImageSource } from './models';
|
||||
|
||||
// ========================================
|
||||
// GENERATION ENDPOINTS
|
||||
// ========================================
|
||||
|
||||
export interface CreateGenerationRequest {
|
||||
prompt: string;
|
||||
referenceImages?: string[]; // Array of aliases to resolve
|
||||
aspectRatio?: string; // e.g., "1:1", "16:9", "3:2", "9:16"
|
||||
flowId?: string;
|
||||
alias?: string; // Alias to assign to generated image
|
||||
flowAlias?: string; // Flow-scoped alias to assign
|
||||
autoEnhance?: boolean;
|
||||
enhancementOptions?: {
|
||||
template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general';
|
||||
};
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListGenerationsQuery {
|
||||
flowId?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
includeDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface RetryGenerationRequest {
|
||||
prompt?: string; // Optional: override original prompt
|
||||
aspectRatio?: string; // Optional: override original aspect ratio
|
||||
}
|
||||
|
||||
export interface UpdateGenerationRequest {
|
||||
prompt?: string; // Change prompt (triggers regeneration)
|
||||
aspectRatio?: string; // Change aspect ratio (triggers regeneration)
|
||||
flowId?: string | null; // Change/remove/add flow association (null to detach)
|
||||
meta?: Record<string, unknown>; // Update metadata
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// IMAGE ENDPOINTS
|
||||
// ========================================
|
||||
|
||||
export interface UploadImageRequest {
|
||||
alias?: string; // Project-scoped alias
|
||||
flowId?: string;
|
||||
flowAlias?: string; // Flow-scoped alias
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListImagesQuery {
|
||||
flowId?: string;
|
||||
source?: ImageSource;
|
||||
alias?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
includeDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateImageRequest {
|
||||
// Removed alias (Section 6.1) - use PUT /images/:id/alias instead
|
||||
focalPoint?: {
|
||||
x: number; // 0.0 to 1.0
|
||||
y: number; // 0.0 to 1.0
|
||||
};
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DeleteImageQuery {
|
||||
hard?: boolean; // If true, perform hard delete; otherwise soft delete
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// FLOW ENDPOINTS
|
||||
// ========================================
|
||||
|
||||
export interface CreateFlowRequest {
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListFlowsQuery {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface UpdateFlowAliasesRequest {
|
||||
aliases: Record<string, string>; // { alias: imageId }
|
||||
merge?: boolean; // If true, merge with existing; otherwise replace
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// LIVE GENERATION ENDPOINT
|
||||
// ========================================
|
||||
|
||||
export interface LiveGenerationQuery {
|
||||
prompt: string;
|
||||
aspectRatio?: string;
|
||||
autoEnhance?: boolean;
|
||||
template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general';
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// LIVE SCOPE ENDPOINTS
|
||||
// ========================================
|
||||
|
||||
export interface CreateLiveScopeRequest {
|
||||
slug: string;
|
||||
allowNewGenerations?: boolean;
|
||||
newGenerationsLimit?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListLiveScopesQuery {
|
||||
slug?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface UpdateLiveScopeRequest {
|
||||
allowNewGenerations?: boolean;
|
||||
newGenerationsLimit?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RegenerateScopeRequest {
|
||||
imageId?: string; // Optional: regenerate specific image
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ANALYTICS ENDPOINTS
|
||||
// ========================================
|
||||
|
||||
export interface AnalyticsSummaryQuery {
|
||||
flowId?: string;
|
||||
startDate?: string; // ISO date string
|
||||
endDate?: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface AnalyticsTimelineQuery {
|
||||
flowId?: string;
|
||||
startDate?: string; // ISO date string
|
||||
endDate?: string; // ISO date string
|
||||
granularity?: 'hour' | 'day' | 'week';
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// COMMON TYPES
|
||||
// ========================================
|
||||
|
||||
export interface PaginationQuery {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
import type {
|
||||
Image,
|
||||
GenerationWithRelations,
|
||||
FlowWithCounts,
|
||||
LiveScopeWithStats,
|
||||
PaginationMeta,
|
||||
AliasScope,
|
||||
} from './models';
|
||||
|
||||
// ========================================
|
||||
// COMMON RESPONSE TYPES
|
||||
// ========================================
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
success: boolean;
|
||||
data: T[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// GENERATION RESPONSES
|
||||
// ========================================
|
||||
|
||||
export interface GenerationResponse {
|
||||
id: string;
|
||||
projectId: string;
|
||||
flowId: string | null;
|
||||
prompt: string; // Prompt actually used for generation
|
||||
originalPrompt: string | null; // User's original input (always populated for new generations)
|
||||
autoEnhance: boolean; // Whether prompt enhancement was applied
|
||||
aspectRatio: string | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
retryCount: number;
|
||||
processingTimeMs: number | null;
|
||||
cost: number | null;
|
||||
outputImageId: string | null;
|
||||
outputImage?: ImageResponse | undefined;
|
||||
referencedImages?: Array<{ imageId: string; alias: string }> | undefined;
|
||||
referenceImages?: ImageResponse[] | undefined;
|
||||
apiKeyId: string | null;
|
||||
meta: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CreateGenerationResponse = ApiResponse<GenerationResponse>;
|
||||
export type GetGenerationResponse = ApiResponse<GenerationResponse>;
|
||||
export type ListGenerationsResponse = PaginatedResponse<GenerationResponse>;
|
||||
export type RetryGenerationResponse = ApiResponse<GenerationResponse>;
|
||||
export type DeleteGenerationResponse = ApiResponse<{ id: string; deletedAt: string }>;
|
||||
|
||||
// ========================================
|
||||
// IMAGE RESPONSES
|
||||
// ========================================
|
||||
|
||||
export interface ImageResponse {
|
||||
id: string;
|
||||
projectId: string;
|
||||
flowId: string | null;
|
||||
storageKey: string;
|
||||
storageUrl: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
source: string;
|
||||
alias: string | null;
|
||||
focalPoint: { x: number; y: number } | null;
|
||||
fileHash: string | null;
|
||||
generationId: string | null;
|
||||
apiKeyId: string | null;
|
||||
meta: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AliasResolutionResponse {
|
||||
alias: string;
|
||||
imageId: string;
|
||||
scope: AliasScope;
|
||||
flowId?: string | undefined;
|
||||
image: ImageResponse;
|
||||
}
|
||||
|
||||
export type UploadImageResponse = ApiResponse<ImageResponse>;
|
||||
export type GetImageResponse = ApiResponse<ImageResponse>;
|
||||
export type ListImagesResponse = PaginatedResponse<ImageResponse>;
|
||||
export type ResolveAliasResponse = ApiResponse<AliasResolutionResponse>;
|
||||
export type UpdateImageResponse = ApiResponse<ImageResponse>;
|
||||
export type DeleteImageResponse = ApiResponse<{ id: string }>; // Hard delete, no deletedAt
|
||||
|
||||
// ========================================
|
||||
// FLOW RESPONSES
|
||||
// ========================================
|
||||
|
||||
export interface FlowResponse {
|
||||
id: string;
|
||||
projectId: string;
|
||||
aliases: Record<string, string>;
|
||||
generationCount: number;
|
||||
imageCount: number;
|
||||
meta: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FlowWithDetailsResponse extends FlowResponse {
|
||||
generations?: GenerationResponse[];
|
||||
images?: ImageResponse[];
|
||||
resolvedAliases?: Record<string, ImageResponse>;
|
||||
}
|
||||
|
||||
export type CreateFlowResponse = ApiResponse<FlowResponse>;
|
||||
export type GetFlowResponse = ApiResponse<FlowResponse>;
|
||||
export type ListFlowsResponse = PaginatedResponse<FlowResponse>;
|
||||
export type UpdateFlowAliasesResponse = ApiResponse<FlowResponse>;
|
||||
export type DeleteFlowAliasResponse = ApiResponse<FlowResponse>;
|
||||
export type DeleteFlowResponse = ApiResponse<{ id: string }>;
|
||||
export type ListFlowGenerationsResponse = PaginatedResponse<GenerationResponse>;
|
||||
export type ListFlowImagesResponse = PaginatedResponse<ImageResponse>;
|
||||
|
||||
// ========================================
|
||||
// LIVE SCOPE RESPONSES
|
||||
// ========================================
|
||||
|
||||
export interface LiveScopeResponse {
|
||||
id: string;
|
||||
projectId: string;
|
||||
slug: string;
|
||||
allowNewGenerations: boolean;
|
||||
newGenerationsLimit: number;
|
||||
currentGenerations: number;
|
||||
lastGeneratedAt: string | null;
|
||||
meta: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LiveScopeWithImagesResponse extends LiveScopeResponse {
|
||||
images?: ImageResponse[];
|
||||
}
|
||||
|
||||
export type CreateLiveScopeResponse = ApiResponse<LiveScopeResponse>;
|
||||
export type GetLiveScopeResponse = ApiResponse<LiveScopeResponse>;
|
||||
export type ListLiveScopesResponse = PaginatedResponse<LiveScopeResponse>;
|
||||
export type UpdateLiveScopeResponse = ApiResponse<LiveScopeResponse>;
|
||||
export type DeleteLiveScopeResponse = ApiResponse<{ id: string }>;
|
||||
export type RegenerateScopeResponse = ApiResponse<{ regenerated: number; images: ImageResponse[] }>;
|
||||
|
||||
// ========================================
|
||||
// LIVE GENERATION RESPONSE
|
||||
// ========================================
|
||||
// Note: Live generation streams image bytes directly
|
||||
// Response headers include:
|
||||
// - Content-Type: image/jpeg
|
||||
// - Cache-Control: public, max-age=31536000
|
||||
// - X-Cache-Status: HIT | MISS
|
||||
|
||||
// ========================================
|
||||
// ANALYTICS RESPONSES
|
||||
// ========================================
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
projectId: string;
|
||||
flowId?: string;
|
||||
timeRange: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
generations: {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
successRate: number;
|
||||
};
|
||||
images: {
|
||||
total: number;
|
||||
generated: number;
|
||||
uploaded: number;
|
||||
};
|
||||
performance: {
|
||||
avgProcessingTimeMs: number;
|
||||
totalCostCents: number;
|
||||
};
|
||||
cache: {
|
||||
hits: number;
|
||||
misses: number;
|
||||
hitRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyticsTimelineData {
|
||||
timestamp: string;
|
||||
generationsTotal: number;
|
||||
generationsSuccess: number;
|
||||
generationsFailed: number;
|
||||
avgProcessingTimeMs: number;
|
||||
costCents: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTimeline {
|
||||
projectId: string;
|
||||
flowId?: string;
|
||||
granularity: 'hour' | 'day' | 'week';
|
||||
timeRange: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
data: AnalyticsTimelineData[];
|
||||
}
|
||||
|
||||
export type GetAnalyticsSummaryResponse = ApiResponse<AnalyticsSummary>;
|
||||
export type GetAnalyticsTimelineResponse = ApiResponse<AnalyticsTimeline>;
|
||||
|
||||
// ========================================
|
||||
// ERROR RESPONSES
|
||||
// ========================================
|
||||
|
||||
export interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// HELPER TYPE CONVERTERS
|
||||
// ========================================
|
||||
|
||||
export const toGenerationResponse = (gen: GenerationWithRelations): GenerationResponse => ({
|
||||
id: gen.id,
|
||||
projectId: gen.projectId,
|
||||
flowId: gen.flowId ?? gen.pendingFlowId ?? null, // Return actual flowId or pendingFlowId for client
|
||||
prompt: gen.prompt, // Prompt actually used
|
||||
originalPrompt: gen.originalPrompt, // User's original (always populated)
|
||||
autoEnhance: gen.prompt !== gen.originalPrompt, // True if prompts differ (enhancement happened)
|
||||
aspectRatio: gen.aspectRatio,
|
||||
status: gen.status,
|
||||
errorMessage: gen.errorMessage,
|
||||
retryCount: gen.retryCount,
|
||||
processingTimeMs: gen.processingTimeMs,
|
||||
cost: gen.cost,
|
||||
outputImageId: gen.outputImageId,
|
||||
outputImage: gen.outputImage ? toImageResponse(gen.outputImage) : undefined,
|
||||
referencedImages: gen.referencedImages as Array<{ imageId: string; alias: string }> | undefined,
|
||||
referenceImages: gen.referenceImages?.map((img) => toImageResponse(img)),
|
||||
apiKeyId: gen.apiKeyId,
|
||||
meta: gen.meta as Record<string, unknown>,
|
||||
createdAt: gen.createdAt.toISOString(),
|
||||
updatedAt: gen.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
export const toImageResponse = (img: Image): ImageResponse => ({
|
||||
id: img.id,
|
||||
projectId: img.projectId,
|
||||
flowId: img.flowId ?? img.pendingFlowId ?? null, // Return actual flowId or pendingFlowId for client
|
||||
storageKey: img.storageKey,
|
||||
storageUrl: img.storageUrl,
|
||||
mimeType: img.mimeType,
|
||||
fileSize: img.fileSize,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
source: img.source,
|
||||
alias: img.alias,
|
||||
focalPoint: img.focalPoint as { x: number; y: number } | null,
|
||||
fileHash: img.fileHash,
|
||||
generationId: img.generationId,
|
||||
apiKeyId: img.apiKeyId,
|
||||
meta: img.meta as Record<string, unknown>,
|
||||
createdAt: img.createdAt.toISOString(),
|
||||
updatedAt: img.updatedAt.toISOString(),
|
||||
deletedAt: img.deletedAt?.toISOString() ?? null,
|
||||
});
|
||||
|
||||
export const toFlowResponse = (flow: FlowWithCounts): FlowResponse => ({
|
||||
id: flow.id,
|
||||
projectId: flow.projectId,
|
||||
aliases: flow.aliases as Record<string, string>,
|
||||
generationCount: flow.generationCount,
|
||||
imageCount: flow.imageCount,
|
||||
meta: flow.meta as Record<string, unknown>,
|
||||
createdAt: flow.createdAt.toISOString(),
|
||||
updatedAt: flow.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
export const toLiveScopeResponse = (scope: LiveScopeWithStats): LiveScopeResponse => ({
|
||||
id: scope.id,
|
||||
projectId: scope.projectId,
|
||||
slug: scope.slug,
|
||||
allowNewGenerations: scope.allowNewGenerations,
|
||||
newGenerationsLimit: scope.newGenerationsLimit,
|
||||
currentGenerations: scope.currentGenerations,
|
||||
lastGeneratedAt: scope.lastGeneratedAt?.toISOString() ?? null,
|
||||
meta: scope.meta as Record<string, unknown>,
|
||||
createdAt: scope.createdAt.toISOString(),
|
||||
updatedAt: scope.updatedAt.toISOString(),
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
export const TECHNICAL_ALIASES = ['@last', '@first', '@upload'] as const;
|
||||
|
||||
export const RESERVED_ALIASES = [
|
||||
...TECHNICAL_ALIASES,
|
||||
'@all',
|
||||
'@latest',
|
||||
'@oldest',
|
||||
'@random',
|
||||
'@next',
|
||||
'@prev',
|
||||
'@previous',
|
||||
] as const;
|
||||
|
||||
export const ALIAS_PATTERN = /^@[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export const ALIAS_MAX_LENGTH = 50;
|
||||
|
||||
export type TechnicalAlias = (typeof TECHNICAL_ALIASES)[number];
|
||||
export type ReservedAlias = (typeof RESERVED_ALIASES)[number];
|
||||
|
||||
export const isTechnicalAlias = (alias: string): alias is TechnicalAlias => {
|
||||
return TECHNICAL_ALIASES.includes(alias as TechnicalAlias);
|
||||
};
|
||||
|
||||
export const isReservedAlias = (alias: string): alias is ReservedAlias => {
|
||||
return RESERVED_ALIASES.includes(alias as ReservedAlias);
|
||||
};
|
||||
|
||||
export const isValidAliasFormat = (alias: string): boolean => {
|
||||
return ALIAS_PATTERN.test(alias) && alias.length <= ALIAS_MAX_LENGTH;
|
||||
};
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
export const ERROR_MESSAGES = {
|
||||
// Authentication & Authorization
|
||||
INVALID_API_KEY: 'Invalid or expired API key',
|
||||
MISSING_API_KEY: 'API key is required',
|
||||
UNAUTHORIZED: 'Unauthorized access',
|
||||
MASTER_KEY_REQUIRED: 'Master key required for this operation',
|
||||
PROJECT_KEY_REQUIRED: 'Project key required for this operation',
|
||||
|
||||
// Validation
|
||||
INVALID_ALIAS_FORMAT: 'Alias must start with @ and contain only alphanumeric characters, hyphens, and underscores',
|
||||
RESERVED_ALIAS: 'This alias is reserved and cannot be used',
|
||||
ALIAS_CONFLICT: 'An image with this alias already exists in this scope',
|
||||
INVALID_PAGINATION: 'Invalid pagination parameters',
|
||||
INVALID_UUID: 'Invalid UUID format',
|
||||
INVALID_ASPECT_RATIO: 'Invalid aspect ratio format',
|
||||
INVALID_FOCAL_POINT: 'Focal point coordinates must be between 0.0 and 1.0',
|
||||
|
||||
// Not Found
|
||||
GENERATION_NOT_FOUND: 'Generation not found',
|
||||
IMAGE_NOT_FOUND: 'Image not found',
|
||||
FLOW_NOT_FOUND: 'Flow not found',
|
||||
ALIAS_NOT_FOUND: 'Alias not found',
|
||||
PROJECT_NOT_FOUND: 'Project not found',
|
||||
|
||||
// Resource Limits
|
||||
MAX_REFERENCE_IMAGES_EXCEEDED: 'Maximum number of reference images exceeded',
|
||||
MAX_FILE_SIZE_EXCEEDED: 'File size exceeds maximum allowed size',
|
||||
RATE_LIMIT_EXCEEDED: 'Rate limit exceeded',
|
||||
MAX_ALIASES_EXCEEDED: 'Maximum number of aliases per flow exceeded',
|
||||
|
||||
// Generation Errors
|
||||
GENERATION_FAILED: 'Image generation failed',
|
||||
GENERATION_PENDING: 'Generation is still pending',
|
||||
REFERENCE_IMAGE_RESOLUTION_FAILED: 'Failed to resolve reference image alias',
|
||||
|
||||
// Live Scope Errors
|
||||
SCOPE_INVALID_FORMAT: 'Live scope format is invalid',
|
||||
SCOPE_CREATION_DISABLED: 'Creation of new live scopes is disabled for this project',
|
||||
SCOPE_GENERATION_LIMIT_EXCEEDED: 'Live scope generation limit exceeded',
|
||||
|
||||
// Storage Errors
|
||||
STORAGE_DELETE_FAILED: 'Failed to delete file from storage',
|
||||
|
||||
// Flow Errors
|
||||
TECHNICAL_ALIAS_REQUIRES_FLOW: 'Technical aliases (@last, @first, @upload) require a flowId',
|
||||
FLOW_HAS_NO_GENERATIONS: 'Flow has no generations',
|
||||
FLOW_HAS_NO_UPLOADS: 'Flow has no uploaded images',
|
||||
ALIAS_NOT_IN_FLOW: 'Alias not found in flow',
|
||||
|
||||
// General
|
||||
INTERNAL_SERVER_ERROR: 'Internal server error',
|
||||
INVALID_REQUEST: 'Invalid request',
|
||||
OPERATION_FAILED: 'Operation failed',
|
||||
} as const;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
// Authentication & Authorization
|
||||
INVALID_API_KEY: 'INVALID_API_KEY',
|
||||
MISSING_API_KEY: 'MISSING_API_KEY',
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
MASTER_KEY_REQUIRED: 'MASTER_KEY_REQUIRED',
|
||||
PROJECT_KEY_REQUIRED: 'PROJECT_KEY_REQUIRED',
|
||||
|
||||
// Validation
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
INVALID_ALIAS_FORMAT: 'INVALID_ALIAS_FORMAT',
|
||||
RESERVED_ALIAS: 'RESERVED_ALIAS',
|
||||
ALIAS_CONFLICT: 'ALIAS_CONFLICT',
|
||||
INVALID_PAGINATION: 'INVALID_PAGINATION',
|
||||
INVALID_UUID: 'INVALID_UUID',
|
||||
INVALID_ASPECT_RATIO: 'INVALID_ASPECT_RATIO',
|
||||
INVALID_FOCAL_POINT: 'INVALID_FOCAL_POINT',
|
||||
|
||||
// Not Found
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
GENERATION_NOT_FOUND: 'GENERATION_NOT_FOUND',
|
||||
IMAGE_NOT_FOUND: 'IMAGE_NOT_FOUND',
|
||||
FLOW_NOT_FOUND: 'FLOW_NOT_FOUND',
|
||||
ALIAS_NOT_FOUND: 'ALIAS_NOT_FOUND',
|
||||
PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND',
|
||||
|
||||
// Resource Limits
|
||||
RESOURCE_LIMIT_EXCEEDED: 'RESOURCE_LIMIT_EXCEEDED',
|
||||
MAX_REFERENCE_IMAGES_EXCEEDED: 'MAX_REFERENCE_IMAGES_EXCEEDED',
|
||||
MAX_FILE_SIZE_EXCEEDED: 'MAX_FILE_SIZE_EXCEEDED',
|
||||
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
||||
MAX_ALIASES_EXCEEDED: 'MAX_ALIASES_EXCEEDED',
|
||||
|
||||
// Generation Errors
|
||||
GENERATION_FAILED: 'GENERATION_FAILED',
|
||||
GENERATION_PENDING: 'GENERATION_PENDING',
|
||||
REFERENCE_IMAGE_RESOLUTION_FAILED: 'REFERENCE_IMAGE_RESOLUTION_FAILED',
|
||||
|
||||
// Live Scope Errors
|
||||
SCOPE_INVALID_FORMAT: 'SCOPE_INVALID_FORMAT',
|
||||
SCOPE_CREATION_DISABLED: 'SCOPE_CREATION_DISABLED',
|
||||
SCOPE_GENERATION_LIMIT_EXCEEDED: 'SCOPE_GENERATION_LIMIT_EXCEEDED',
|
||||
|
||||
// Storage Errors
|
||||
STORAGE_DELETE_FAILED: 'STORAGE_DELETE_FAILED',
|
||||
|
||||
// Flow Errors
|
||||
TECHNICAL_ALIAS_REQUIRES_FLOW: 'TECHNICAL_ALIAS_REQUIRES_FLOW',
|
||||
FLOW_HAS_NO_GENERATIONS: 'FLOW_HAS_NO_GENERATIONS',
|
||||
FLOW_HAS_NO_UPLOADS: 'FLOW_HAS_NO_UPLOADS',
|
||||
ALIAS_NOT_IN_FLOW: 'ALIAS_NOT_IN_FLOW',
|
||||
|
||||
// General
|
||||
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||
INVALID_REQUEST: 'INVALID_REQUEST',
|
||||
OPERATION_FAILED: 'OPERATION_FAILED',
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
export type ErrorMessage = (typeof ERROR_MESSAGES)[keyof typeof ERROR_MESSAGES];
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './aliases';
|
||||
export * from './limits';
|
||||
export * from './errors';
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
export const RATE_LIMITS = {
|
||||
master: {
|
||||
requests: {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 1000,
|
||||
},
|
||||
generations: {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
requests: {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 500,
|
||||
},
|
||||
generations: {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 50,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const PAGINATION_LIMITS = {
|
||||
DEFAULT_LIMIT: 20,
|
||||
MAX_LIMIT: 100,
|
||||
MIN_LIMIT: 1,
|
||||
DEFAULT_OFFSET: 0,
|
||||
} as const;
|
||||
|
||||
export const IMAGE_LIMITS = {
|
||||
MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB
|
||||
MAX_REFERENCE_IMAGES: 3,
|
||||
MAX_WIDTH: 8192,
|
||||
MAX_HEIGHT: 8192,
|
||||
ALLOWED_MIME_TYPES: ['image/jpeg', 'image/png', 'image/webp'] as const,
|
||||
} as const;
|
||||
|
||||
export const GENERATION_LIMITS = {
|
||||
MAX_PROMPT_LENGTH: 5000,
|
||||
MAX_RETRY_COUNT: 3,
|
||||
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;
|
||||
|
||||
export const FLOW_LIMITS = {
|
||||
MAX_NAME_LENGTH: 100,
|
||||
MAX_DESCRIPTION_LENGTH: 500,
|
||||
MAX_ALIASES_PER_FLOW: 50,
|
||||
} as const;
|
||||
|
||||
export const CACHE_LIMITS = {
|
||||
PRESIGNED_URL_EXPIRY: 24 * 60 * 60, // 24 hours in seconds
|
||||
CACHE_MAX_AGE: 365 * 24 * 60 * 60, // 1 year in seconds
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Compute cache key for live URL generation (Section 8.7)
|
||||
*
|
||||
* Cache key format: SHA-256 hash of (projectId + scope + prompt + params)
|
||||
*
|
||||
* @param projectId - Project UUID
|
||||
* @param scope - Live scope slug
|
||||
* @param prompt - User prompt
|
||||
* @param params - Additional generation parameters (aspectRatio, etc.)
|
||||
* @returns SHA-256 hash string
|
||||
*/
|
||||
export const computeLiveUrlCacheKey = (
|
||||
projectId: string,
|
||||
scope: string,
|
||||
prompt: string,
|
||||
params: {
|
||||
aspectRatio?: string;
|
||||
autoEnhance?: boolean;
|
||||
template?: string;
|
||||
} = {},
|
||||
): string => {
|
||||
// Normalize parameters to ensure consistent cache keys
|
||||
const normalizedParams = {
|
||||
aspectRatio: params.aspectRatio || '1:1',
|
||||
autoEnhance: params.autoEnhance ?? false,
|
||||
template: params.template || 'general',
|
||||
};
|
||||
|
||||
// Create cache key string
|
||||
const cacheKeyString = [
|
||||
projectId,
|
||||
scope,
|
||||
prompt.trim().toLowerCase(), // Normalize prompt
|
||||
normalizedParams.aspectRatio,
|
||||
normalizedParams.autoEnhance.toString(),
|
||||
normalizedParams.template,
|
||||
].join('::');
|
||||
|
||||
// Compute SHA-256 hash
|
||||
return crypto.createHash('sha256').update(cacheKeyString).digest('hex');
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute prompt hash for prompt URL cache (Section 5 - already implemented)
|
||||
*
|
||||
* @param prompt - User prompt
|
||||
* @returns SHA-256 hash string
|
||||
*/
|
||||
export const computePromptHash = (prompt: string): string => {
|
||||
return crypto.createHash('sha256').update(prompt.trim().toLowerCase()).digest('hex');
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
export const computeSHA256 = (data: string | Buffer): string => {
|
||||
return crypto.createHash('sha256').update(data).digest('hex');
|
||||
};
|
||||
|
||||
export const computeCacheKey = (prompt: string, params: Record<string, unknown>): string => {
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
const sortedParams: Record<string, unknown> = {};
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
sortedParams[key] = params[key];
|
||||
}
|
||||
|
||||
const combined = prompt + JSON.stringify(sortedParams);
|
||||
return computeSHA256(combined);
|
||||
};
|
||||
|
||||
export const computeFileHash = (buffer: Buffer): string => {
|
||||
return computeSHA256(buffer);
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './paginationBuilder';
|
||||
export * from './hashHelper';
|
||||
export * from './queryHelper';
|
||||
export * from './cacheKeyHelper';
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { PaginationMeta } from '@/types/models';
|
||||
import type { PaginatedResponse } from '@/types/responses';
|
||||
|
||||
export const buildPaginationMeta = (
|
||||
total: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): PaginationMeta => {
|
||||
return {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildPaginatedResponse = <T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): PaginatedResponse<T> => {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
pagination: buildPaginationMeta(total, limit, offset),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { and, eq, isNull, SQL } from 'drizzle-orm';
|
||||
|
||||
export const buildWhereClause = (conditions: (SQL | undefined)[]): SQL | undefined => {
|
||||
const validConditions = conditions.filter((c): c is SQL => c !== undefined);
|
||||
|
||||
if (validConditions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (validConditions.length === 1) {
|
||||
return validConditions[0];
|
||||
}
|
||||
|
||||
return and(...validConditions);
|
||||
};
|
||||
|
||||
export const withoutDeleted = <T extends { deletedAt: any }>(
|
||||
table: T,
|
||||
includeDeleted = false
|
||||
): SQL | undefined => {
|
||||
if (includeDeleted) {
|
||||
return undefined;
|
||||
}
|
||||
return isNull(table.deletedAt as any);
|
||||
};
|
||||
|
||||
export const buildEqCondition = <T, K extends keyof T>(
|
||||
table: T,
|
||||
column: K,
|
||||
value: unknown
|
||||
): SQL | undefined => {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
return eq(table[column] as any, value);
|
||||
};
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import {
|
||||
ALIAS_PATTERN,
|
||||
ALIAS_MAX_LENGTH,
|
||||
isReservedAlias,
|
||||
isTechnicalAlias,
|
||||
isValidAliasFormat
|
||||
} from '../constants/aliases';
|
||||
import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors';
|
||||
|
||||
export interface AliasValidationResult {
|
||||
valid: boolean;
|
||||
error?: {
|
||||
message: string;
|
||||
code: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const validateAliasFormat = (alias: string): AliasValidationResult => {
|
||||
if (!alias) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: 'Alias is required',
|
||||
code: ERROR_CODES.VALIDATION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!alias.startsWith('@')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.INVALID_ALIAS_FORMAT,
|
||||
code: ERROR_CODES.INVALID_ALIAS_FORMAT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (alias.length > ALIAS_MAX_LENGTH) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: `Alias must not exceed ${ALIAS_MAX_LENGTH} characters`,
|
||||
code: ERROR_CODES.VALIDATION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!ALIAS_PATTERN.test(alias)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.INVALID_ALIAS_FORMAT,
|
||||
code: ERROR_CODES.INVALID_ALIAS_FORMAT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const validateAliasNotReserved = (alias: string): AliasValidationResult => {
|
||||
if (isReservedAlias(alias)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.RESERVED_ALIAS,
|
||||
code: ERROR_CODES.RESERVED_ALIAS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const validateAliasForAssignment = (alias: string): AliasValidationResult => {
|
||||
const formatResult = validateAliasFormat(alias);
|
||||
if (!formatResult.valid) {
|
||||
return formatResult;
|
||||
}
|
||||
|
||||
return validateAliasNotReserved(alias);
|
||||
};
|
||||
|
||||
export const validateTechnicalAliasWithFlow = (
|
||||
alias: string,
|
||||
flowId?: string
|
||||
): AliasValidationResult => {
|
||||
if (isTechnicalAlias(alias) && !flowId) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.TECHNICAL_ALIAS_REQUIRES_FLOW,
|
||||
code: ERROR_CODES.TECHNICAL_ALIAS_REQUIRES_FLOW,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract all aliases from a prompt text
|
||||
* Pattern: space followed by @ followed by alphanumeric, dash, or underscore
|
||||
* Example: "Create image based on @hero and @background" -> ["@hero", "@background"]
|
||||
*/
|
||||
export const extractAliasesFromPrompt = (prompt: string): string[] => {
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Pattern: space then @ then word characters (including dash and underscore)
|
||||
// Also match @ at the beginning of the string
|
||||
const aliasPattern = /(?:^|\s)(@[\w-]+)/g;
|
||||
const matches: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = aliasPattern.exec(prompt)) !== null) {
|
||||
const alias = match[1]!;
|
||||
// Validate format and max length
|
||||
if (isValidAliasFormat(alias)) {
|
||||
matches.push(alias);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates while preserving order
|
||||
return Array.from(new Set(matches));
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './aliasValidator';
|
||||
export * from './paginationValidator';
|
||||
export * from './queryValidator';
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { PAGINATION_LIMITS } from '../constants/limits';
|
||||
import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors';
|
||||
|
||||
export interface PaginationParams {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface PaginationValidationResult {
|
||||
valid: boolean;
|
||||
params?: PaginationParams;
|
||||
error?: {
|
||||
message: string;
|
||||
code: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const validateAndNormalizePagination = (
|
||||
limit?: number | string,
|
||||
offset?: number | string
|
||||
): PaginationValidationResult => {
|
||||
const parsedLimit =
|
||||
typeof limit === 'string' ? parseInt(limit, 10) : limit ?? PAGINATION_LIMITS.DEFAULT_LIMIT;
|
||||
const parsedOffset =
|
||||
typeof offset === 'string' ? parseInt(offset, 10) : offset ?? PAGINATION_LIMITS.DEFAULT_OFFSET;
|
||||
|
||||
if (isNaN(parsedLimit) || isNaN(parsedOffset)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.INVALID_PAGINATION,
|
||||
code: ERROR_CODES.INVALID_PAGINATION,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (parsedLimit < PAGINATION_LIMITS.MIN_LIMIT || parsedLimit > PAGINATION_LIMITS.MAX_LIMIT) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: `Limit must be between ${PAGINATION_LIMITS.MIN_LIMIT} and ${PAGINATION_LIMITS.MAX_LIMIT}`,
|
||||
code: ERROR_CODES.INVALID_PAGINATION,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (parsedOffset < 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: 'Offset must be non-negative',
|
||||
code: ERROR_CODES.INVALID_PAGINATION,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
params: {
|
||||
limit: parsedLimit,
|
||||
offset: parsedOffset,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors';
|
||||
import { GENERATION_LIMITS } from '../constants/limits';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: {
|
||||
message: string;
|
||||
code: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const validateUUID = (id: string): ValidationResult => {
|
||||
const uuidPattern =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
if (!uuidPattern.test(id)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.INVALID_UUID,
|
||||
code: ERROR_CODES.INVALID_UUID,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const validateAspectRatio = (aspectRatio: string): ValidationResult => {
|
||||
if (!GENERATION_LIMITS.ALLOWED_ASPECT_RATIOS.includes(aspectRatio as any)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: `Invalid aspect ratio. Allowed values: ${GENERATION_LIMITS.ALLOWED_ASPECT_RATIOS.join(', ')}`,
|
||||
code: ERROR_CODES.INVALID_ASPECT_RATIO,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const validateFocalPoint = (focalPoint: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): ValidationResult => {
|
||||
if (
|
||||
focalPoint.x < 0 ||
|
||||
focalPoint.x > 1 ||
|
||||
focalPoint.y < 0 ||
|
||||
focalPoint.y > 1
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: ERROR_MESSAGES.INVALID_FOCAL_POINT,
|
||||
code: ERROR_CODES.INVALID_FOCAL_POINT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const validateDateRange = (
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): ValidationResult => {
|
||||
if (startDate && isNaN(Date.parse(startDate))) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: 'Invalid start date format',
|
||||
code: ERROR_CODES.VALIDATION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endDate && isNaN(Date.parse(endDate))) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: 'Invalid end date format',
|
||||
code: ERROR_CODES.VALIDATION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (startDate && endDate && new Date(startDate) > new Date(endDate)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
message: 'Start date must be before end date',
|
||||
code: ERROR_CODES.VALIDATION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'export',
|
||||
output: 'standalone',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,17 +10,18 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"@banatie/database": "workspace:*",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "15.5.4",
|
||||
"@banatie/database": "workspace:*"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { createProjectApiKey, listApiKeys } from '@/lib/actions/apiKeyActions';
|
|||
import KeyDisplay from '@/components/admin/KeyDisplay';
|
||||
import AdminFormInput from '@/components/admin/AdminFormInput';
|
||||
import AdminButton from '@/components/admin/AdminButton';
|
||||
import Link from 'next/link';
|
||||
import { Section } from '@/components/shared/Section';
|
||||
|
||||
const STORAGE_KEY = 'banatie_master_key';
|
||||
|
||||
|
|
@ -28,13 +28,16 @@ export default function ApiKeysPage() {
|
|||
router.push('/admin/master');
|
||||
} else {
|
||||
setMasterKey(saved);
|
||||
loadApiKeys();
|
||||
// Load API keys with the saved master key
|
||||
listApiKeys(saved).then(setApiKeys);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
const keys = await listApiKeys();
|
||||
setApiKeys(keys);
|
||||
if (masterKey) {
|
||||
const keys = await listApiKeys(masterKey);
|
||||
setApiKeys(keys);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
|
|
@ -66,23 +69,7 @@ export default function ApiKeysPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-10 max-w-6xl mx-auto px-6 py-16">
|
||||
{/* Navigation */}
|
||||
<div className="mb-8 flex gap-4">
|
||||
<Link
|
||||
href="/admin/master"
|
||||
className="px-4 py-2 bg-slate-700 text-slate-300 rounded-lg font-medium hover:bg-slate-600"
|
||||
>
|
||||
Master Key
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/apikeys"
|
||||
className="px-4 py-2 bg-amber-600 text-white rounded-lg font-medium"
|
||||
>
|
||||
API Keys
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Section>
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>
|
||||
|
|
@ -202,6 +189,6 @@ export default function ApiKeysPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { SubsectionNav } from '@/components/shared/SubsectionNav';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Master Key', href: '/admin/master' },
|
||||
{ label: 'API Keys', href: '/admin/apikeys' },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||
{/* Animated gradient background */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-1/4 w-96 h-96 bg-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-cyan-600/10 rounded-full blur-3xl animate-pulse delay-700"></div>
|
||||
</div>
|
||||
|
||||
{/* Subsection Navigation */}
|
||||
<SubsectionNav items={navItems} currentPath={pathname} />
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="relative z-10">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { bootstrapMasterKey } from '@/lib/actions/apiKeyActions';
|
|||
import KeyDisplay from '@/components/admin/KeyDisplay';
|
||||
import AdminFormInput from '@/components/admin/AdminFormInput';
|
||||
import AdminButton from '@/components/admin/AdminButton';
|
||||
import Link from 'next/link';
|
||||
import { NarrowSection } from '@/components/shared/NarrowSection';
|
||||
|
||||
const STORAGE_KEY = 'banatie_master_key';
|
||||
|
||||
|
|
@ -67,23 +67,7 @@ export default function MasterKeyPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-6 py-16">
|
||||
{/* Navigation */}
|
||||
<div className="mb-8 flex gap-4">
|
||||
<Link
|
||||
href="/admin/master"
|
||||
className="px-4 py-2 bg-amber-600 text-white rounded-lg font-medium"
|
||||
>
|
||||
Master Key
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/apikeys"
|
||||
className="px-4 py-2 bg-slate-700 text-slate-300 rounded-lg font-medium hover:bg-slate-600"
|
||||
>
|
||||
API Keys
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<NarrowSection>
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>
|
||||
|
|
@ -149,6 +133,6 @@ export default function MasterKeyPage() {
|
|||
Save Manual Key
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</NarrowSection>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
|
||||
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
|
||||
import { Section } from '@/components/shared/Section';
|
||||
import { ImageGrid } from '@/components/demo/gallery/ImageGrid';
|
||||
import { EmptyGalleryState } from '@/components/demo/gallery/EmptyGalleryState';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||
const IMAGES_PER_PAGE = 30;
|
||||
|
||||
type ImageItem = {
|
||||
|
|
@ -31,22 +30,13 @@ type ImagesResponse = {
|
|||
message?: string;
|
||||
};
|
||||
|
||||
type ApiKeyInfo = {
|
||||
organizationSlug?: string;
|
||||
projectSlug?: string;
|
||||
};
|
||||
|
||||
type DownloadTimeMap = {
|
||||
[imageId: string]: number;
|
||||
};
|
||||
|
||||
export default function GalleryPage() {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
||||
const [apiKeyValidated, setApiKeyValidated] = useState(false);
|
||||
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
|
||||
const [apiKeyError, setApiKeyError] = useState('');
|
||||
const [validatingKey, setValidatingKey] = useState(false);
|
||||
// API Key from context
|
||||
const { apiKey, apiKeyValidated, focus } = useApiKey();
|
||||
|
||||
const [images, setImages] = useState<ImageItem[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
|
@ -55,114 +45,13 @@ export default function GalleryPage() {
|
|||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [zoomedImageUrl, setZoomedImageUrl] = useState<string | null>(null);
|
||||
const [downloadTimes, setDownloadTimes] = useState<DownloadTimeMap>({});
|
||||
|
||||
useEffect(() => {
|
||||
const storedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY);
|
||||
if (storedApiKey) {
|
||||
setApiKey(storedApiKey);
|
||||
validateStoredApiKey(storedApiKey);
|
||||
if (apiKeyValidated) {
|
||||
fetchImages(apiKey, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const validateStoredApiKey = async (keyToValidate: string) => {
|
||||
setValidatingKey(true);
|
||||
setApiKeyError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||
headers: {
|
||||
'X-API-Key': keyToValidate,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApiKeyValidated(true);
|
||||
if (data.keyInfo) {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||
});
|
||||
} else {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: 'Unknown',
|
||||
projectSlug: 'Unknown',
|
||||
});
|
||||
}
|
||||
await fetchImages(keyToValidate, 0);
|
||||
} else {
|
||||
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||
setApiKeyError('Stored API key is invalid or expired');
|
||||
setApiKeyValidated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setApiKeyError('Failed to validate stored API key');
|
||||
setApiKeyValidated(false);
|
||||
} finally {
|
||||
setValidatingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateApiKey = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setApiKeyError('Please enter an API key');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingKey(true);
|
||||
setApiKeyError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||
headers: {
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApiKeyValidated(true);
|
||||
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey);
|
||||
|
||||
if (data.keyInfo) {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||
});
|
||||
} else {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: 'Unknown',
|
||||
projectSlug: 'Unknown',
|
||||
});
|
||||
}
|
||||
|
||||
await fetchImages(apiKey, 0);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setApiKeyError(error.message || 'Invalid API key');
|
||||
setApiKeyValidated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setApiKeyError('Failed to validate API key. Please check your connection.');
|
||||
setApiKeyValidated(false);
|
||||
} finally {
|
||||
setValidatingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeApiKey = () => {
|
||||
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||
setApiKey('');
|
||||
setApiKeyValidated(false);
|
||||
setApiKeyInfo(null);
|
||||
setApiKeyError('');
|
||||
setImages([]);
|
||||
setOffset(0);
|
||||
setHasMore(false);
|
||||
setError('');
|
||||
};
|
||||
}, [apiKeyValidated]);
|
||||
|
||||
const fetchImages = async (keyToUse: string, fetchOffset: number) => {
|
||||
if (fetchOffset === 0) {
|
||||
|
|
@ -223,16 +112,7 @@ export default function GalleryPage() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 py-12 md:py-16 min-h-screen">
|
||||
{apiKeyValidated && apiKeyInfo && (
|
||||
<MinimizedApiKey
|
||||
organizationSlug={apiKeyInfo.organizationSlug || 'Unknown'}
|
||||
projectSlug={apiKeyInfo.projectSlug || 'Unknown'}
|
||||
apiKey={apiKey}
|
||||
onRevoke={revokeApiKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section className="py-12 md:py-16 min-h-screen">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
||||
Image Gallery
|
||||
|
|
@ -242,82 +122,22 @@ export default function GalleryPage() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
{/* API Key Required Notice - Only show when not validated */}
|
||||
{!apiKeyValidated && (
|
||||
<section
|
||||
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
||||
aria-label="API Key Validation"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type={apiKeyVisible ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
validateApiKey();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your API key"
|
||||
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent pr-12"
|
||||
aria-label="API key input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiKeyVisible(!apiKeyVisible)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 text-gray-400 hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500 rounded"
|
||||
aria-label={apiKeyVisible ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{apiKeyVisible ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="mb-6 p-5 bg-amber-900/10 border border-amber-700/50 rounded-2xl">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">API Key Required</h3>
|
||||
<p className="text-sm text-gray-400">Enter your API key to browse your images</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={validateApiKey}
|
||||
disabled={validatingKey}
|
||||
className="px-6 py-3 rounded-lg bg-amber-600 text-white font-semibold hover:bg-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-slate-950 min-h-[44px]"
|
||||
aria-busy={validatingKey}
|
||||
onClick={focus}
|
||||
className="px-5 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{validatingKey ? 'Validating...' : 'Validate'}
|
||||
Enter API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{apiKeyError && (
|
||||
<div className="mt-3 p-3 bg-red-900/20 border border-red-700/50 rounded-lg" role="alert" aria-live="assertive">
|
||||
<p className="text-sm text-red-400 font-medium mb-1">{apiKeyError}</p>
|
||||
<p className="text-xs text-red-300/80">
|
||||
{apiKeyError.includes('Invalid')
|
||||
? 'Please check your API key and try again. You can create a new key in the admin dashboard.'
|
||||
: 'Please check your internet connection and try again.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeyValidated && (
|
||||
|
|
@ -342,7 +162,6 @@ export default function GalleryPage() {
|
|||
<>
|
||||
<ImageGrid
|
||||
images={images}
|
||||
onImageZoom={setZoomedImageUrl}
|
||||
onDownloadMeasured={handleDownloadMeasured}
|
||||
/>
|
||||
|
||||
|
|
@ -370,8 +189,6 @@ export default function GalleryPage() {
|
|||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ImageZoomModal imageUrl={zoomedImageUrl} onClose={() => setZoomedImageUrl(null)} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
interface DemoLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Text to Image', href: '/demo/tti' },
|
||||
{ label: 'Upload', href: '/demo/upload' },
|
||||
{ label: 'Gallery', href: '/demo/gallery' },
|
||||
];
|
||||
|
||||
export default function DemoLayout({ children }: DemoLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<ApiKeyProvider>
|
||||
<PageProvider
|
||||
navItems={navItems}
|
||||
currentPath={pathname}
|
||||
rightSlot={<ApiKeyWidget />}
|
||||
>
|
||||
{children}
|
||||
</PageProvider>
|
||||
</ApiKeyProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||
import { useState, useRef, KeyboardEvent } from 'react';
|
||||
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
|
||||
import { Section } from '@/components/shared/Section';
|
||||
import { GenerationTimer } from '@/components/demo/GenerationTimer';
|
||||
import { ResultCard } from '@/components/demo/ResultCard';
|
||||
import { AdvancedOptionsModal, AdvancedOptionsData } from '@/components/demo/AdvancedOptionsModal';
|
||||
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||
|
||||
// Generate random 6-character uppercase ID for pairing images
|
||||
function generatePairId(): string {
|
||||
|
|
@ -51,19 +50,9 @@ interface GenerationResult {
|
|||
} & AdvancedOptionsData;
|
||||
}
|
||||
|
||||
interface ApiKeyInfo {
|
||||
organizationSlug?: string;
|
||||
projectSlug?: string;
|
||||
}
|
||||
|
||||
export default function DemoTTIPage() {
|
||||
// API Key State
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
||||
const [apiKeyValidated, setApiKeyValidated] = useState(false);
|
||||
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
|
||||
const [apiKeyError, setApiKeyError] = useState('');
|
||||
const [validatingKey, setValidatingKey] = useState(false);
|
||||
// API Key from context
|
||||
const { apiKey, apiKeyValidated, focus } = useApiKey();
|
||||
|
||||
// Prompt State
|
||||
const [prompt, setPrompt] = useState('');
|
||||
|
|
@ -80,123 +69,8 @@ export default function DemoTTIPage() {
|
|||
// Results State
|
||||
const [results, setResults] = useState<GenerationResult[]>([]);
|
||||
|
||||
// Modal State
|
||||
const [zoomedImage, setZoomedImage] = useState<string | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Load API key from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY);
|
||||
if (storedApiKey) {
|
||||
setApiKey(storedApiKey);
|
||||
// Auto-validate the stored key
|
||||
validateStoredApiKey(storedApiKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate stored API key (without user interaction)
|
||||
const validateStoredApiKey = async (keyToValidate: string) => {
|
||||
setValidatingKey(true);
|
||||
setApiKeyError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||
headers: {
|
||||
'X-API-Key': keyToValidate,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApiKeyValidated(true);
|
||||
if (data.keyInfo) {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||
});
|
||||
} else {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: 'Unknown',
|
||||
projectSlug: 'Unknown',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Stored key is invalid, clear it
|
||||
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||
setApiKeyError('Stored API key is invalid or expired');
|
||||
setApiKeyValidated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setApiKeyError('Failed to validate stored API key');
|
||||
setApiKeyValidated(false);
|
||||
} finally {
|
||||
setValidatingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate API Key
|
||||
const validateApiKey = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setApiKeyError('Please enter an API key');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingKey(true);
|
||||
setApiKeyError('');
|
||||
|
||||
try {
|
||||
// Test API key with a minimal request to /api/info or similar
|
||||
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||
headers: {
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApiKeyValidated(true);
|
||||
|
||||
// Save to localStorage on successful validation
|
||||
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey);
|
||||
|
||||
// Extract org/project info from API response
|
||||
if (data.keyInfo) {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||
});
|
||||
} else {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: 'Unknown',
|
||||
projectSlug: 'Unknown',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setApiKeyError(error.message || 'Invalid API key');
|
||||
setApiKeyValidated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setApiKeyError('Failed to validate API key. Please check your connection.');
|
||||
setApiKeyValidated(false);
|
||||
} finally {
|
||||
setValidatingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Revoke API Key
|
||||
const revokeApiKey = () => {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||
|
||||
// Clear state
|
||||
setApiKey('');
|
||||
setApiKeyValidated(false);
|
||||
setApiKeyInfo(null);
|
||||
setApiKeyError('');
|
||||
};
|
||||
|
||||
// Generate Images
|
||||
const generateImages = async () => {
|
||||
if (!prompt.trim()) {
|
||||
|
|
@ -380,17 +254,7 @@ export default function DemoTTIPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 py-12 md:py-16 min-h-screen">
|
||||
{/* Minimized API Key Badge */}
|
||||
{apiKeyValidated && apiKeyInfo && (
|
||||
<MinimizedApiKey
|
||||
organizationSlug={apiKeyInfo.organizationSlug || 'Unknown'}
|
||||
projectSlug={apiKeyInfo.projectSlug || 'Unknown'}
|
||||
apiKey={apiKey}
|
||||
onRevoke={revokeApiKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section className="py-12 md:py-16 min-h-screen">
|
||||
{/* Page Header */}
|
||||
<header className="mb-8 md:mb-12">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
||||
|
|
@ -401,77 +265,22 @@ export default function DemoTTIPage() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
{/* API Key Section - Only show when not validated */}
|
||||
{/* API Key Required Notice - Only show when not validated */}
|
||||
{!apiKeyValidated && (
|
||||
<section
|
||||
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
||||
aria-label="API Key Validation"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type={apiKeyVisible ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
validateApiKey();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your API key"
|
||||
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent pr-12"
|
||||
aria-label="API key input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiKeyVisible(!apiKeyVisible)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||
aria-label={apiKeyVisible ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{apiKeyVisible ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="mb-6 p-5 bg-amber-900/10 border border-amber-700/50 rounded-2xl">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">API Key Required</h3>
|
||||
<p className="text-sm text-gray-400">Enter your API key to use this workbench</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={validateApiKey}
|
||||
disabled={validatingKey}
|
||||
className="px-6 py-3 rounded-lg bg-amber-600 text-white font-semibold hover:bg-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-amber-500"
|
||||
onClick={focus}
|
||||
className="px-5 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{validatingKey ? 'Validating...' : 'Validate'}
|
||||
Enter API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{apiKeyError && (
|
||||
<p className="mt-3 text-sm text-red-400" role="alert">
|
||||
{apiKeyError}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified Prompt & Generation Card */}
|
||||
|
|
@ -612,7 +421,6 @@ export default function DemoTTIPage() {
|
|||
key={result.id}
|
||||
result={result}
|
||||
apiKey={apiKey}
|
||||
onZoom={setZoomedImage}
|
||||
onCopy={copyToClipboard}
|
||||
onDownload={downloadImage}
|
||||
onReusePrompt={reusePrompt}
|
||||
|
|
@ -620,9 +428,6 @@ export default function DemoTTIPage() {
|
|||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Zoom Modal */}
|
||||
<ImageZoomModal imageUrl={zoomedImage} onClose={() => setZoomedImage(null)} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, DragEvent, ChangeEvent } from 'react';
|
||||
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||
import { useState, useEffect, useRef, useCallback, DragEvent, ChangeEvent } from 'react';
|
||||
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
|
||||
import { Section } from '@/components/shared/Section';
|
||||
import { CodeExamplesWidget } from '@/components/demo/CodeExamplesWidget';
|
||||
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
|
||||
import { SelectedFileCodePreview } from '@/components/demo/SelectedFileCodePreview';
|
||||
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
||||
import { ImageCard } from '@/components/shared/ImageCard';
|
||||
import { calculateAspectRatio } from '@/utils/imageUtils';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||
const UPLOAD_HISTORY_KEY = 'banatie_upload_history';
|
||||
|
||||
const ALLOWED_FILE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||
|
|
@ -46,19 +45,9 @@ interface UploadHistoryItem {
|
|||
downloadMs?: number;
|
||||
}
|
||||
|
||||
interface ApiKeyInfo {
|
||||
organizationSlug?: string;
|
||||
projectSlug?: string;
|
||||
}
|
||||
|
||||
export default function DemoUploadPage() {
|
||||
// API Key State
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
||||
const [apiKeyValidated, setApiKeyValidated] = useState(false);
|
||||
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
|
||||
const [apiKeyError, setApiKeyError] = useState('');
|
||||
const [validatingKey, setValidatingKey] = useState(false);
|
||||
// API Key from context
|
||||
const { apiKey, apiKeyValidated, focus } = useApiKey();
|
||||
|
||||
// Upload State
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
|
@ -76,24 +65,12 @@ export default function DemoUploadPage() {
|
|||
// History State
|
||||
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
||||
|
||||
// Zoom Modal State
|
||||
const [zoomedImageUrl, setZoomedImageUrl] = useState<string | null>(null);
|
||||
|
||||
// Copy Feedback State
|
||||
const [codeCopied, setCodeCopied] = useState(false);
|
||||
|
||||
// Refs
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load API key from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY);
|
||||
if (storedApiKey) {
|
||||
setApiKey(storedApiKey);
|
||||
validateStoredApiKey(storedApiKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load upload history from sessionStorage
|
||||
useEffect(() => {
|
||||
const storedHistory = sessionStorage.getItem(UPLOAD_HISTORY_KEY);
|
||||
|
|
@ -119,97 +96,6 @@ export default function DemoUploadPage() {
|
|||
}
|
||||
}, [uploadHistory]);
|
||||
|
||||
const validateStoredApiKey = async (keyToValidate: string) => {
|
||||
setValidatingKey(true);
|
||||
setApiKeyError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||
headers: {
|
||||
'X-API-Key': keyToValidate,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApiKeyValidated(true);
|
||||
if (data.keyInfo) {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||
});
|
||||
} else {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: 'Unknown',
|
||||
projectSlug: 'Unknown',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||
setApiKeyError('Stored API key is invalid or expired');
|
||||
setApiKeyValidated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setApiKeyError('Failed to validate stored API key');
|
||||
setApiKeyValidated(false);
|
||||
} finally {
|
||||
setValidatingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateApiKey = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setApiKeyError('Please enter an API key');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingKey(true);
|
||||
setApiKeyError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||
headers: {
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApiKeyValidated(true);
|
||||
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey);
|
||||
|
||||
if (data.keyInfo) {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||
});
|
||||
} else {
|
||||
setApiKeyInfo({
|
||||
organizationSlug: 'Unknown',
|
||||
projectSlug: 'Unknown',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setApiKeyError(error.message || 'Invalid API key');
|
||||
setApiKeyValidated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setApiKeyError('Failed to validate API key. Please check your connection.');
|
||||
setApiKeyValidated(false);
|
||||
} finally {
|
||||
setValidatingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeApiKey = () => {
|
||||
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||
setApiKey('');
|
||||
setApiKeyValidated(false);
|
||||
setApiKeyInfo(null);
|
||||
setApiKeyError('');
|
||||
};
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
|
||||
return `Invalid file type. Allowed: PNG, JPEG, JPG, WebP`;
|
||||
|
|
@ -349,13 +235,17 @@ export default function DemoUploadPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDownloadMeasured = (itemId: string, downloadMs: number) => {
|
||||
const handleDownloadMeasured = useCallback((itemId: string, downloadMs: number) => {
|
||||
setUploadHistory((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === itemId ? { ...item, downloadMs } : item
|
||||
)
|
||||
prev.map((item) => {
|
||||
// Only update if this item doesn't have downloadMs yet (prevent re-measuring)
|
||||
if (item.id === itemId && item.downloadMs === undefined) {
|
||||
return { ...item, downloadMs };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const generateUploadCodeExamples = (item: UploadHistoryItem, key: string, baseUrl: string) => {
|
||||
const fileName = item.originalName;
|
||||
|
|
@ -407,16 +297,7 @@ Body (form-data):
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 py-12 md:py-16 min-h-screen">
|
||||
{apiKeyValidated && apiKeyInfo && (
|
||||
<MinimizedApiKey
|
||||
organizationSlug={apiKeyInfo.organizationSlug || 'Unknown'}
|
||||
projectSlug={apiKeyInfo.projectSlug || 'Unknown'}
|
||||
apiKey={apiKey}
|
||||
onRevoke={revokeApiKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section className="py-12 md:py-16 min-h-screen">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
||||
File Upload Workbench
|
||||
|
|
@ -426,76 +307,22 @@ Body (form-data):
|
|||
</p>
|
||||
</header>
|
||||
|
||||
{/* API Key Required Notice - Only show when not validated */}
|
||||
{!apiKeyValidated && (
|
||||
<section
|
||||
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
||||
aria-label="API Key Validation"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type={apiKeyVisible ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
validateApiKey();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your API key"
|
||||
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent pr-12"
|
||||
aria-label="API key input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiKeyVisible(!apiKeyVisible)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||
aria-label={apiKeyVisible ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{apiKeyVisible ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="mb-6 p-5 bg-amber-900/10 border border-amber-700/50 rounded-2xl">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">API Key Required</h3>
|
||||
<p className="text-sm text-gray-400">Enter your API key to use this workbench</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={validateApiKey}
|
||||
disabled={validatingKey}
|
||||
className="px-6 py-3 rounded-lg bg-amber-600 text-white font-semibold hover:bg-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-amber-500"
|
||||
onClick={focus}
|
||||
className="px-5 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{validatingKey ? 'Validating...' : 'Validate'}
|
||||
Enter API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{apiKeyError && (
|
||||
<p className="mt-3 text-sm text-red-400" role="alert">
|
||||
{apiKeyError}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
|
|
@ -643,7 +470,6 @@ Body (form-data):
|
|||
fileSize={item.size}
|
||||
fileType={item.contentType}
|
||||
timestamp={item.timestamp}
|
||||
onZoom={setZoomedImageUrl}
|
||||
measureDownloadTime={true}
|
||||
onDownloadMeasured={(downloadMs) => handleDownloadMeasured(item.id, downloadMs)}
|
||||
/>
|
||||
|
|
@ -661,9 +487,6 @@ Body (form-data):
|
|||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Image Zoom Modal */}
|
||||
<ImageZoomModal imageUrl={zoomedImageUrl} onClose={() => setZoomedImageUrl(null)} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
import { ReactNode } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { SubsectionNav } from '@/components/shared/SubsectionNav';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Root Documentation Layout
|
||||
|
|
@ -44,23 +46,12 @@ export default function DocsRootLayout({ children }: DocsRootLayoutProps) {
|
|||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||
{/* Animated gradient background (matching landing page) */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-1/4 w-96 h-96 bg-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-cyan-600/10 rounded-full blur-3xl animate-pulse delay-700"></div>
|
||||
</div>
|
||||
|
||||
{/* Subsection Navigation */}
|
||||
<SubsectionNav
|
||||
items={navItems}
|
||||
<ApiKeyProvider>
|
||||
<PageProvider
|
||||
navItems={navItems}
|
||||
currentPath={pathname}
|
||||
ctaText="Join Beta"
|
||||
ctaHref="/signup"
|
||||
/>
|
||||
|
||||
{/* Three-column Documentation Layout */}
|
||||
<div className="relative z-10">
|
||||
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">
|
||||
|
|
@ -69,7 +60,7 @@ export default function DocsRootLayout({ children }: DocsRootLayoutProps) {
|
|||
}
|
||||
center={children}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageProvider>
|
||||
</ApiKeyProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,15 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 3s ease infinite;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
'use client';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
'use client';
|
||||
|
||||
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 (
|
||||
<>
|
||||
{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%)` }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
'use client';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
export default function GlowEffect({ children }: { children: ReactNode }) {
|
||||
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 className="flex items-center justify-center p-4">
|
||||
<div className="email-form-wrapper relative isolate max-w-lg w-full mx-auto p-[2px] rounded-xl">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
|
||||
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%)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Zap, Globe, FlaskConical, AtSign, Link } from 'lucide-react';
|
||||
import GlowEffect from './GlowEffect';
|
||||
|
||||
export const styles = `
|
||||
.gradient-text {
|
||||
background: linear-gradient(90deg, #818cf8 0%, #c084fc 25%, #f472b6 50%, #c084fc 75%, #818cf8 100%);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: gradient-shift 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% { background-position: 100% 50%; }
|
||||
50% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
.beta-dot {
|
||||
animation: beta-dot-delay 20s linear forwards, beta-dot-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) 20s infinite;
|
||||
}
|
||||
|
||||
@keyframes beta-dot-delay {
|
||||
0%, 99% { background-color: rgb(107, 114, 128); }
|
||||
100% { background-color: rgb(74, 222, 128); }
|
||||
}
|
||||
|
||||
@keyframes beta-dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`;
|
||||
|
||||
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() {
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log('Email submitted:', email);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative pt-40 pb-20 px-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/5 border border-white/10 text-gray-400 text-xs mb-8">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-500 beta-dot" />
|
||||
In Active Development
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl lg:text-7xl font-bold mb-6 leading-tight">
|
||||
AI Image Generation
|
||||
<br />
|
||||
<span className="gradient-text">Inside Your Workflow</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-400 mb-10 max-w-2xl mx-auto">
|
||||
Generate images via API, SDK, CLI, Lab, or live URLs.
|
||||
<br />
|
||||
Production-ready CDN delivery in seconds.
|
||||
</p>
|
||||
|
||||
<GlowEffect>
|
||||
<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={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="flex-1 px-4 py-3 bg-transparent border-none rounded-md text-white outline-none placeholder:text-white/40 focus:bg-white/[0.03]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 rounded-md text-white font-semibold cursor-pointer transition-all whitespace-nowrap"
|
||||
>
|
||||
Get Early Access
|
||||
</button>
|
||||
</form>
|
||||
</GlowEffect>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-10">Free early access. No credit card required.</p>
|
||||
|
||||
<div className="flex flex-nowrap gap-5 justify-center">
|
||||
{badges.map((badge, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`px-6 py-2.5 rounded-full text-sm flex items-center 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-4 h-4 ${badge.variant === 'cyan' ? 'text-cyan-400' : 'text-indigo-400'}`}
|
||||
/>
|
||||
{badge.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
'use client';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
'use client';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
'use client';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
'use client';
|
||||
|
||||
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 static sites, prototypes, and AI coding agents that generate HTML.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
'use client';
|
||||
|
||||
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})`;
|
||||
}
|
||||
|
||||
export 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: '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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export { BackgroundBlobs } from './BackgroundBlobs';
|
||||
export { HeroGlow } from './HeroGlow';
|
||||
export { HeroSection, styles as heroStyles } 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, styles as shapeFutureStyles } from './ShapeTheFutureSection';
|
||||
export { GeminiSection } from './GeminiSection';
|
||||
export { FinalCtaSection } from './FinalCtaSection';
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
BackgroundBlobs,
|
||||
HeroGlow,
|
||||
HeroSection,
|
||||
heroStyles,
|
||||
ApiExampleSection,
|
||||
ProblemSolutionSection,
|
||||
PromptUrlsSection,
|
||||
HowItWorksSection,
|
||||
KeyFeaturesSection,
|
||||
IntegrationsSection,
|
||||
ShapeTheFutureSection,
|
||||
shapeFutureStyles,
|
||||
GeminiSection,
|
||||
FinalCtaSection,
|
||||
} from './_components';
|
||||
|
||||
const customStyles = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@500;600;700&display=swap');
|
||||
|
||||
${heroStyles}
|
||||
|
||||
${shapeFutureStyles}
|
||||
`;
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: customStyles }} />
|
||||
<div className="relative">
|
||||
<BackgroundBlobs />
|
||||
<HeroGlow />
|
||||
|
||||
<HeroSection />
|
||||
<ApiExampleSection />
|
||||
<ProblemSolutionSection />
|
||||
<PromptUrlsSection />
|
||||
<HowItWorksSection />
|
||||
<KeyFeaturesSection />
|
||||
<IntegrationsSection />
|
||||
<ShapeTheFutureSection />
|
||||
<GeminiSection />
|
||||
<FinalCtaSection />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import Image from 'next/image';
|
||||
import { Footer } from '@/components/shared/Footer';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({
|
||||
|
|
@ -101,39 +102,7 @@ export default function RootLayout({
|
|||
{/* Page content */}
|
||||
{children}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 border-t border-white/10 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-6 pt-12 pb-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<div className="h-16 flex items-center">
|
||||
<Image
|
||||
src="/banatie-logo-horisontal.png"
|
||||
alt="Banatie Logo"
|
||||
width={200}
|
||||
height={60}
|
||||
className="h-full w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-8 text-sm text-gray-400">
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
Documentation
|
||||
</a>
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
API Reference
|
||||
</a>
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
Pricing
|
||||
</a>
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
Contact
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 text-center text-sm text-gray-500">
|
||||
© 2025 Banatie. Built for builders who create.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<Footer />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface ImageZoomModalProps {
|
||||
imageUrl: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ImageZoomModal = ({ imageUrl, onClose }: ImageZoomModalProps) => {
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (imageUrl) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
// Focus trap
|
||||
const previousActiveElement = document.activeElement as HTMLElement;
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
// Disable body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = '';
|
||||
previousActiveElement?.focus();
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [imageUrl, onClose]);
|
||||
|
||||
if (!imageUrl) return null;
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === modalRef.current) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4 sm:p-6 md:p-8"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<div className="absolute top-4 right-4 sm:top-6 sm:right-6 flex items-center gap-3">
|
||||
<span id="modal-title" className="sr-only">Full size image viewer</span>
|
||||
<span className="hidden sm:block text-white/70 text-sm font-medium">Press ESC to close</span>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
className="w-11 h-11 sm:w-12 sm:h-12 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-black/90"
|
||||
aria-label="Close zoomed image"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Full size view"
|
||||
className="max-w-full max-h-full object-contain rounded-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,6 +5,8 @@ import { InspectMode } from './InspectMode';
|
|||
import { PromptReuseButton } from './PromptReuseButton';
|
||||
import { CompletedTimerBadge } from './GenerationTimer';
|
||||
import { CodeExamplesWidget } from './CodeExamplesWidget';
|
||||
import { usePageContext } from '@/contexts/page-context';
|
||||
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
|
|
@ -51,7 +53,6 @@ interface GenerationResult {
|
|||
interface ResultCardProps {
|
||||
result: GenerationResult;
|
||||
apiKey: string;
|
||||
onZoom: (url: string) => void;
|
||||
onCopy: (text: string) => void;
|
||||
onDownload: (url: string, filename: string) => void;
|
||||
onReusePrompt: (prompt: string) => void;
|
||||
|
|
@ -62,11 +63,11 @@ type ViewMode = 'preview' | 'inspect';
|
|||
export function ResultCard({
|
||||
result,
|
||||
apiKey,
|
||||
onZoom,
|
||||
onCopy,
|
||||
onDownload,
|
||||
onReusePrompt,
|
||||
}: ResultCardProps) {
|
||||
const { openModal } = usePageContext();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('preview');
|
||||
|
||||
// Build enhancement options JSON for code examples
|
||||
|
|
@ -213,7 +214,7 @@ X-API-Key: ${apiKey}
|
|||
image={result.leftImage}
|
||||
label="Original Prompt"
|
||||
prompt={result.originalPrompt}
|
||||
onZoom={onZoom}
|
||||
openModal={openModal}
|
||||
onDownload={onDownload}
|
||||
onReusePrompt={onReusePrompt}
|
||||
filename="original.png"
|
||||
|
|
@ -224,7 +225,7 @@ X-API-Key: ${apiKey}
|
|||
image={result.rightImage}
|
||||
label="Enhanced Prompt"
|
||||
prompt={result.enhancedPrompt || result.originalPrompt}
|
||||
onZoom={onZoom}
|
||||
openModal={openModal}
|
||||
onDownload={onDownload}
|
||||
onReusePrompt={onReusePrompt}
|
||||
filename="enhanced.png"
|
||||
|
|
@ -264,7 +265,7 @@ function ImagePreview({
|
|||
image,
|
||||
label,
|
||||
prompt,
|
||||
onZoom,
|
||||
openModal,
|
||||
onDownload,
|
||||
onReusePrompt,
|
||||
filename,
|
||||
|
|
@ -273,7 +274,7 @@ function ImagePreview({
|
|||
image: GenerationResult['leftImage'];
|
||||
label: string;
|
||||
prompt: string;
|
||||
onZoom: (url: string) => void;
|
||||
openModal: (content: React.ReactNode) => void;
|
||||
onDownload: (url: string, filename: string) => void;
|
||||
onReusePrompt: (prompt: string) => void;
|
||||
filename: string;
|
||||
|
|
@ -282,6 +283,12 @@ function ImagePreview({
|
|||
const [promptExpanded, setPromptExpanded] = useState(false);
|
||||
const [urlCopied, setUrlCopied] = useState(false);
|
||||
|
||||
const handleImageClick = () => {
|
||||
if (image?.url) {
|
||||
openModal(<ExpandedImageView imageUrl={image.url} alt={label} />);
|
||||
}
|
||||
};
|
||||
|
||||
const copyImageUrl = () => {
|
||||
if (image?.url) {
|
||||
navigator.clipboard.writeText(image.url);
|
||||
|
|
@ -318,7 +325,7 @@ function ImagePreview({
|
|||
src={image.url}
|
||||
alt={label}
|
||||
className="h-96 w-auto object-contain rounded-lg border border-slate-700"
|
||||
onClick={() => onZoom(image.url)}
|
||||
onClick={handleImageClick}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
|
|||
import { useImageDownloadTime } from '@/components/shared/ImageCard/useImageDownloadTime';
|
||||
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
||||
import { calculateAspectRatio } from '@/utils/imageUtils';
|
||||
import { usePageContext } from '@/contexts/page-context';
|
||||
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
|
||||
|
||||
type GalleryImageCardProps = {
|
||||
imageUrl: string;
|
||||
|
|
@ -12,7 +14,6 @@ type GalleryImageCardProps = {
|
|||
size: number;
|
||||
contentType: string;
|
||||
lastModified: string;
|
||||
onZoom: (url: string) => void;
|
||||
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
||||
};
|
||||
|
||||
|
|
@ -22,9 +23,9 @@ export const GalleryImageCard = ({
|
|||
size,
|
||||
contentType,
|
||||
lastModified,
|
||||
onZoom,
|
||||
onDownloadMeasured,
|
||||
}: GalleryImageCardProps) => {
|
||||
const { openModal } = usePageContext();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState<{
|
||||
width: number;
|
||||
|
|
@ -57,10 +58,14 @@ export const GalleryImageCard = ({
|
|||
img.src = imageUrl;
|
||||
}, [isVisible, imageUrl]);
|
||||
|
||||
const handleImageClick = () => {
|
||||
openModal(<ExpandedImageView imageUrl={imageUrl} alt={filename} />);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onZoom(imageUrl);
|
||||
handleImageClick();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -81,7 +86,7 @@ export const GalleryImageCard = ({
|
|||
>
|
||||
<div
|
||||
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||
onClick={() => onZoom(imageUrl)}
|
||||
onClick={handleImageClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View full size image: ${filename}`}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ type ImageItem = {
|
|||
|
||||
type ImageGridProps = {
|
||||
images: ImageItem[];
|
||||
onImageZoom: (url: string) => void;
|
||||
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
||||
};
|
||||
|
||||
export const ImageGrid = ({ images, onImageZoom, onDownloadMeasured }: ImageGridProps) => {
|
||||
export const ImageGrid = ({ images, onDownloadMeasured }: ImageGridProps) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{images.map((image) => (
|
||||
|
|
@ -27,7 +26,6 @@ export const ImageGrid = ({ images, onImageZoom, onDownloadMeasured }: ImageGrid
|
|||
size={image.size}
|
||||
contentType={image.contentType}
|
||||
lastModified={image.lastModified}
|
||||
onZoom={onImageZoom}
|
||||
onDownloadMeasured={onDownloadMeasured}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@
|
|||
* Include the <InlineCode color="neutral">X-API-Key</InlineCode> header.
|
||||
*
|
||||
* // Parameter documentation
|
||||
* The <InlineCode>autoEnhance</InlineCode> parameter defaults to false.
|
||||
* The <InlineCode>autoEnhance</InlineCode> parameter defaults to true.
|
||||
*
|
||||
* // Error messages
|
||||
* If you receive <InlineCode color="error">401 Unauthorized</InlineCode>, check your API key.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
|
||||
/**
|
||||
* Interactive API Widget - Production Version
|
||||
*
|
||||
|
|
@ -60,6 +61,8 @@ export const InteractiveAPIWidget = ({
|
|||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [paramValues, setParamValues] = useState<Record<string, string>>({});
|
||||
|
||||
const {apiKeyValidated, focus} = useApiKey()
|
||||
|
||||
// Load API key from localStorage and listen for changes
|
||||
useEffect(() => {
|
||||
const loadApiKey = () => {
|
||||
|
|
@ -266,7 +269,7 @@ func main() {
|
|||
|
||||
// Expand API key input in navigation
|
||||
const expandApiKey = () => {
|
||||
window.dispatchEvent(new CustomEvent('expandApiKeyInput'));
|
||||
focus();
|
||||
};
|
||||
|
||||
const isSuccess = response && response.success === true;
|
||||
|
|
@ -336,7 +339,7 @@ func main() {
|
|||
onClick={expandApiKey}
|
||||
className="text-sm text-gray-400 hover:text-white underline-offset-4 hover:underline transition-colors"
|
||||
>
|
||||
{apiKey ? 'API Key Set' : 'Enter API Key'}
|
||||
{apiKeyValidated ? 'API Key Set' : 'Enter API Key'}
|
||||
</button>
|
||||
<button
|
||||
onClick={executeRequest}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
'use client';
|
||||
|
||||
export const AnimatedBackground = () => {
|
||||
return (
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none -z-10">
|
||||
<div className="absolute top-1/4 -left-1/4 w-96 h-96 bg-purple-600/10 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-cyan-600/10 rounded-full blur-3xl animate-pulse delay-700" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* API Key Context Provider
|
||||
*
|
||||
* Centralized state management for API key functionality across demo pages.
|
||||
* Provides:
|
||||
* - API key validation and storage
|
||||
* - UI state management (expanded, visibility)
|
||||
* - Focus method for external components
|
||||
* - Automatic validation of stored keys on mount
|
||||
*
|
||||
* Phase 1 of centralizing apikey functionality (previously duplicated across demo pages)
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
validateApiKeyRequest,
|
||||
getStoredKey,
|
||||
setStoredKey,
|
||||
clearStoredKey,
|
||||
type ApiKeyContextValue,
|
||||
type ApiKeyProviderProps,
|
||||
type ApiKeyInfo,
|
||||
} from '@/lib/apikey';
|
||||
|
||||
// Create context with undefined default (will error if used outside provider)
|
||||
const ApiKeyContext = createContext<ApiKeyContextValue | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* API Key Provider Component
|
||||
*
|
||||
* Wraps the application to provide global API key state and actions
|
||||
*/
|
||||
export function ApiKeyProvider({ children, onValidationSuccess }: ApiKeyProviderProps) {
|
||||
// Data State
|
||||
const [apiKey, setApiKeyState] = useState('');
|
||||
const [apiKeyValidated, setApiKeyValidated] = useState(false);
|
||||
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
|
||||
const [apiKeyError, setApiKeyError] = useState('');
|
||||
const [validatingKey, setValidatingKey] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// UI State (grouped)
|
||||
const [expanded, setExpandedState] = useState(false);
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/**
|
||||
* Validate stored API key on mount
|
||||
*/
|
||||
const validateStoredApiKey = useCallback(
|
||||
async (keyToValidate: string) => {
|
||||
setValidatingKey(true);
|
||||
setApiKeyError('');
|
||||
|
||||
const result = await validateApiKeyRequest(keyToValidate);
|
||||
|
||||
if (result.success && result.keyInfo) {
|
||||
setApiKeyValidated(true);
|
||||
setApiKeyInfo(result.keyInfo);
|
||||
setApiKeyState(keyToValidate);
|
||||
|
||||
// Call optional success callback
|
||||
onValidationSuccess?.(result.keyInfo);
|
||||
} else {
|
||||
// Stored key is invalid, clear it
|
||||
clearStoredKey();
|
||||
setApiKeyError(result.error || 'Stored API key is invalid or expired');
|
||||
setApiKeyValidated(false);
|
||||
}
|
||||
|
||||
setValidatingKey(false);
|
||||
setIsReady(true);
|
||||
},
|
||||
[onValidationSuccess]
|
||||
);
|
||||
|
||||
/**
|
||||
* Initialize: check for stored API key
|
||||
*/
|
||||
useEffect(() => {
|
||||
const storedKey = getStoredKey();
|
||||
if (storedKey) {
|
||||
validateStoredApiKey(storedKey);
|
||||
} else {
|
||||
setIsReady(true);
|
||||
}
|
||||
}, [validateStoredApiKey]);
|
||||
|
||||
/**
|
||||
* Validate API key (user-triggered)
|
||||
*/
|
||||
const validateApiKey = useCallback(async () => {
|
||||
setValidatingKey(true);
|
||||
setApiKeyError('');
|
||||
|
||||
const result = await validateApiKeyRequest(apiKey);
|
||||
|
||||
if (result.success && result.keyInfo) {
|
||||
setApiKeyValidated(true);
|
||||
setApiKeyInfo(result.keyInfo);
|
||||
setStoredKey(apiKey);
|
||||
|
||||
// Collapse widget after successful validation
|
||||
setExpandedState(false);
|
||||
|
||||
// Call optional success callback
|
||||
onValidationSuccess?.(result.keyInfo);
|
||||
} else {
|
||||
setApiKeyError(result.error || 'Validation failed');
|
||||
setApiKeyValidated(false);
|
||||
}
|
||||
|
||||
setValidatingKey(false);
|
||||
}, [apiKey, onValidationSuccess]);
|
||||
|
||||
/**
|
||||
* Revoke API key
|
||||
* Optionally accepts a cleanup function for page-specific state
|
||||
*/
|
||||
const revokeApiKey = useCallback((clearPageState?: () => void) => {
|
||||
clearStoredKey();
|
||||
setApiKeyState('');
|
||||
setApiKeyValidated(false);
|
||||
setApiKeyInfo(null);
|
||||
setApiKeyError('');
|
||||
setExpandedState(true); // Expand to show input after revoke
|
||||
|
||||
// Call optional page-specific cleanup
|
||||
clearPageState?.();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Toggle API key visibility
|
||||
*/
|
||||
const toggleKeyVisibility = useCallback(() => {
|
||||
setKeyVisible((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set expanded state
|
||||
*/
|
||||
const setExpanded = useCallback((value: boolean) => {
|
||||
setExpandedState(value);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set API key value
|
||||
*/
|
||||
const setApiKey = useCallback((key: string) => {
|
||||
setApiKeyState(key);
|
||||
setApiKeyError(''); // Clear error when user types
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Focus method - for external components to trigger focus
|
||||
* Expands widget, focuses input, and scrolls into view
|
||||
*/
|
||||
const focus = useCallback(() => {
|
||||
setExpandedState(true);
|
||||
|
||||
// Wait for expansion animation, then focus
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
// TODO: don't need because it's always visible
|
||||
// containerRef.current?.scrollIntoView({
|
||||
// behavior: 'smooth',
|
||||
// block: 'center',
|
||||
// });
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const value: ApiKeyContextValue = {
|
||||
// Data State
|
||||
apiKey,
|
||||
apiKeyValidated,
|
||||
apiKeyInfo,
|
||||
apiKeyError,
|
||||
validatingKey,
|
||||
isReady,
|
||||
|
||||
// UI State
|
||||
ui: {
|
||||
expanded,
|
||||
keyVisible,
|
||||
},
|
||||
|
||||
// Actions
|
||||
setApiKey,
|
||||
validateApiKey,
|
||||
revokeApiKey,
|
||||
toggleKeyVisibility,
|
||||
setExpanded,
|
||||
|
||||
// Focus method
|
||||
focus,
|
||||
inputRef,
|
||||
containerRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<ApiKeyContext.Provider value={value}>
|
||||
<div ref={containerRef}>{children}</div>
|
||||
</ApiKeyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useApiKey Hook
|
||||
*
|
||||
* Access API key context values and actions
|
||||
* Must be used within ApiKeyProvider
|
||||
*
|
||||
* @example
|
||||
* const { apiKey, apiKeyValidated, validateApiKey, focus } = useApiKey();
|
||||
*/
|
||||
export function useApiKey(): ApiKeyContextValue {
|
||||
const context = useContext(ApiKeyContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useApiKey must be used within an ApiKeyProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// Export inputRef for ApiKeyWidget to use
|
||||
export { ApiKeyContext };
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* API Key Widget Component
|
||||
*
|
||||
* Global API key management widget displayed in navigation bar.
|
||||
* Features:
|
||||
* - Inline collapsed state (fits in nav rightSlot)
|
||||
* - Popup expanded state (absolute positioned dropdown)
|
||||
* - API key validation and management
|
||||
* - Click outside and Escape key to close
|
||||
* - Focus method accessible via useApiKey hook
|
||||
*
|
||||
* Usage:
|
||||
* Must be used within ApiKeyProvider context
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useApiKey } from './apikey-context';
|
||||
|
||||
export function ApiKeyWidget() {
|
||||
const {
|
||||
apiKey,
|
||||
apiKeyValidated,
|
||||
apiKeyInfo,
|
||||
apiKeyError,
|
||||
validatingKey,
|
||||
ui,
|
||||
inputRef,
|
||||
containerRef,
|
||||
setApiKey,
|
||||
validateApiKey,
|
||||
revokeApiKey,
|
||||
toggleKeyVisibility,
|
||||
setExpanded,
|
||||
} = useApiKey();
|
||||
|
||||
// const containerRef = useRef<HTMLDivElement>(null);
|
||||
// const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isMobileContext, setIsMobileContext] = useState(false);
|
||||
|
||||
// Detect mobile context (inside mobile dropdown menu)
|
||||
useEffect(() => {
|
||||
const checkContext = () => {
|
||||
if (containerRef.current) {
|
||||
// Check if ancestor has md:hidden class (mobile menu)
|
||||
const mobileMenu = containerRef.current.closest('.md\\:hidden');
|
||||
setIsMobileContext(mobileMenu !== null);
|
||||
}
|
||||
};
|
||||
|
||||
checkContext();
|
||||
window.addEventListener('resize', checkContext);
|
||||
return () => window.removeEventListener('resize', checkContext);
|
||||
}, []);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
if (!ui.expanded) return;
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [ui.expanded, setExpanded]);
|
||||
|
||||
// Escape key to close
|
||||
useEffect(() => {
|
||||
if (!ui.expanded) return;
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [ui.expanded, setExpanded]);
|
||||
|
||||
// Handle Enter key for validation
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !validatingKey) {
|
||||
validateApiKey();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop for mobile expanded state */}
|
||||
{ui.expanded && isMobileContext && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-label="Close API key details"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} className={isMobileContext ? "relative" : "relative h-full flex items-center"}>
|
||||
{/* COLLAPSED STATE - Always rendered to maintain layout space */}
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className={`group px-3 bg-transparent backdrop-blur-sm border-none rounded-lg hover:border-purple-500/50 transition-all flex items-center ${
|
||||
isMobileContext
|
||||
? 'w-auto max-w-full py-2.5 h-11' // Mobile: auto-width, larger touch target
|
||||
: 'py-2 h-10' // Desktop: original size
|
||||
} ${
|
||||
ui.expanded && isMobileContext ? 'opacity-50 pointer-events-none' : '' // Fade when expanded on mobile
|
||||
}`}
|
||||
aria-label="Expand API key details"
|
||||
disabled={ui.expanded}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${apiKeyValidated ? 'bg-green-400' : 'bg-gray-500'}`}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300 font-medium whitespace-nowrap">
|
||||
{apiKeyValidated && apiKeyInfo
|
||||
? `${apiKeyInfo.organizationSlug} / ${apiKeyInfo.projectSlug}`
|
||||
: 'API Key'}
|
||||
</span>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400 group-hover:text-purple-400 transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* EXPANDED STATE - Rendered as overlay when expanded */}
|
||||
{ui.expanded && (
|
||||
<div className={`bg-slate-900/95 backdrop-blur-sm border border-slate-700 rounded-xl shadow-2xl animate-in fade-in duration-200 ${
|
||||
isMobileContext
|
||||
? 'fixed inset-x-4 top-16 p-5 z-50 max-h-[calc(100vh-6rem)] overflow-y-auto' // Mobile: fixed positioning for full-width
|
||||
: 'absolute top-0 right-0 w-96 p-5 z-50' // Desktop: absolute positioned
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className={`font-semibold text-white mb-1 ${isMobileContext ? 'text-base' : 'text-sm'}`}>
|
||||
{apiKeyValidated ? 'API Key Active' : 'Enter API Key'}
|
||||
</h3>
|
||||
{apiKeyValidated && apiKeyInfo && (
|
||||
<p className="text-xs text-gray-400">
|
||||
{apiKeyInfo.organizationSlug} / {apiKeyInfo.projectSlug}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className={`rounded-lg bg-slate-800 hover:bg-slate-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors ${
|
||||
isMobileContext ? 'w-10 h-10' : 'w-8 h-8'
|
||||
}`}
|
||||
aria-label="Minimize API key details"
|
||||
>
|
||||
<svg className={isMobileContext ? 'w-5 h-5' : 'w-4 h-4'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API Key Input/Display */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs text-gray-500 mb-2 block">API Key</label>
|
||||
<div className={`flex gap-2 ${isMobileContext ? 'flex-col' : 'flex-row'}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={ui.keyVisible ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter your API key"
|
||||
disabled={apiKeyValidated}
|
||||
className={`flex-1 px-4 bg-slate-800 border border-slate-700 rounded-lg text-gray-300 font-mono placeholder:text-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all ${
|
||||
isMobileContext ? 'py-3 text-base' : 'py-2 text-sm'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={toggleKeyVisibility}
|
||||
className={`bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-gray-400 hover:text-white transition-colors ${
|
||||
isMobileContext ? 'px-4 py-3 w-full justify-center flex items-center gap-2' : 'px-3 py-2'
|
||||
}`}
|
||||
aria-label={ui.keyVisible ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{ui.keyVisible ? (
|
||||
<>
|
||||
<svg className={isMobileContext ? 'w-5 h-5' : 'w-4 h-4'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
{isMobileContext && <span className="text-sm font-medium">Hide</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className={isMobileContext ? 'w-5 h-5' : 'w-4 h-4'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{isMobileContext && <span className="text-sm font-medium">Show</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{apiKeyError && (
|
||||
<div className={`mb-3 bg-red-900/20 border border-red-700/50 rounded-lg ${isMobileContext ? 'p-3' : 'p-2'}`}>
|
||||
<p className="text-xs text-red-400">{apiKeyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{!apiKeyValidated ? (
|
||||
<button
|
||||
onClick={validateApiKey}
|
||||
disabled={validatingKey || !apiKey.trim()}
|
||||
className={`flex-1 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-700 disabled:text-gray-500 disabled:cursor-not-allowed border border-purple-500 disabled:border-slate-600 rounded-lg text-white font-medium transition-colors ${
|
||||
isMobileContext ? 'px-5 py-3.5 text-base' : 'px-4 py-2 text-sm'
|
||||
}`}
|
||||
>
|
||||
{validatingKey ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className={isMobileContext ? 'animate-spin h-5 w-5' : 'animate-spin h-4 w-4'}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Validating...
|
||||
</span>
|
||||
) : (
|
||||
'Validate Key'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => revokeApiKey()}
|
||||
className={`flex-1 bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 hover:border-red-600 rounded-lg text-red-400 font-medium transition-colors ${
|
||||
isMobileContext ? 'px-5 py-3.5 text-base' : 'px-4 py-2 text-sm'
|
||||
}`}
|
||||
>
|
||||
Revoke & Use Different Key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
{!apiKeyValidated && (
|
||||
<p className={`mt-3 text-gray-500 text-center ${isMobileContext ? 'text-sm' : 'text-xs'}`}>
|
||||
Press Enter or click Validate to verify your API key
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { footerLinks, footerCopyright } from '@/config/footer';
|
||||
|
||||
export const CompactFooter = () => {
|
||||
return (
|
||||
<footer
|
||||
className="border-t border-white/10 bg-slate-950/80 backdrop-blur-sm"
|
||||
role="contentinfo"
|
||||
>
|
||||
<div className="hidden md:flex items-center justify-between h-16 px-6 max-w-7xl mx-auto">
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
src="/logo-square.png"
|
||||
alt="Banatie"
|
||||
width={32}
|
||||
height={32}
|
||||
className="opacity-80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Footer navigation" className="flex items-center gap-6">
|
||||
{footerLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="text-sm text-gray-400 hover:text-white transition-colors min-h-[44px] flex items-center"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<p className="text-xs text-gray-500 flex-shrink-0">
|
||||
{footerCopyright}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden px-6 py-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Image
|
||||
src="/logo-square.png"
|
||||
alt="Banatie"
|
||||
width={28}
|
||||
height={28}
|
||||
className="opacity-80"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
© 2025 Banatie
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label="Footer navigation"
|
||||
className="grid grid-cols-2 gap-x-4 gap-y-3"
|
||||
>
|
||||
{footerLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="text-sm text-gray-400 hover:text-white transition-colors min-h-[44px] flex items-center"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
# ExpandedImageView Component
|
||||
|
||||
Displays full-size images in modal overlays with loading, error, and success states.
|
||||
|
||||
## Features
|
||||
|
||||
- **Loading State:** Purple spinner with descriptive text
|
||||
- **Error State:** Clear error message with retry button
|
||||
- **Success State:** Smooth fade-in transition
|
||||
- **Optional Metadata:** Display filename, dimensions, and file size
|
||||
- **Responsive Padding:** Adapts to mobile, tablet, and desktop
|
||||
- **Accessibility:** ARIA live regions, semantic roles, keyboard support
|
||||
|
||||
## Props
|
||||
|
||||
```typescript
|
||||
interface ExpandedImageViewProps {
|
||||
imageUrl: string; // Required: Image URL to display
|
||||
alt?: string; // Optional: Alt text (default: 'Full size view')
|
||||
metadata?: { // Optional: Image metadata
|
||||
filename?: string; // e.g., 'sunset_1024x768.png'
|
||||
size?: string; // e.g., '2.4 MB'
|
||||
dimensions?: string; // e.g., '1024 × 768'
|
||||
};
|
||||
showMetadata?: boolean; // Optional: Show metadata bar (default: false)
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
```tsx
|
||||
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
|
||||
|
||||
export default function ImageModal({ imageUrl }: { imageUrl: string }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 z-50">
|
||||
<ExpandedImageView
|
||||
imageUrl={imageUrl}
|
||||
alt="Generated landscape image"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### With Metadata
|
||||
```tsx
|
||||
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
|
||||
|
||||
export default function ImageGalleryModal({ image }: { image: Image }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 z-50">
|
||||
<ExpandedImageView
|
||||
imageUrl={image.url}
|
||||
alt={image.prompt}
|
||||
showMetadata
|
||||
metadata={{
|
||||
filename: image.filename,
|
||||
dimensions: `${image.width} × ${image.height}`,
|
||||
size: formatFileSize(image.sizeBytes)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### In Page Provider System
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
|
||||
import { CompactFooter } from '@/components/shared/CompactFooter';
|
||||
|
||||
export default function ImageViewerPage() {
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Gallery */}
|
||||
<div className="grid grid-cols-3 gap-4 p-6">
|
||||
{images.map((img) => (
|
||||
<img
|
||||
key={img.id}
|
||||
src={img.thumbnail}
|
||||
onClick={() => setSelectedImage(img.fullUrl)}
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overlay */}
|
||||
{selectedImage && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex flex-col"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<nav className="h-16 border-b border-white/10">
|
||||
<button onClick={() => setSelectedImage(null)}>Close</button>
|
||||
</nav>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<ExpandedImageView
|
||||
imageUrl={selectedImage}
|
||||
alt="Expanded gallery image"
|
||||
showMetadata
|
||||
/>
|
||||
</main>
|
||||
|
||||
<CompactFooter />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
### ARIA Roles
|
||||
- **Loading State:** `role="status"` with `aria-live="polite"`
|
||||
- **Error State:** `role="alert"` with `aria-live="assertive"`
|
||||
- **Icon Elements:** `aria-hidden="true"` (decorative only)
|
||||
|
||||
### Keyboard Support
|
||||
- Retry button is keyboard accessible (Enter/Space)
|
||||
- Image click event stops propagation (prevents modal close)
|
||||
- Integrates with modal close handlers (Escape key)
|
||||
|
||||
### Visual Design
|
||||
- **Loading:** Purple spinner (`border-purple-600`) matches Banatie design
|
||||
- **Error:** Red color scheme (`red-900/20`, `red-700/50`, `red-400`)
|
||||
- **Image:** Shadow (`shadow-2xl`) and rounded corners (`rounded-lg`)
|
||||
- **Metadata:** Subtle bar with Banatie card styling
|
||||
|
||||
## Layout Constraints
|
||||
|
||||
- **Max Image Height:** `calc(100vh - 12rem)` reserves space for nav/footer
|
||||
- **Object Fit:** `object-contain` maintains aspect ratio
|
||||
- **Responsive Padding:** `p-4 sm:p-6 md:p-8`
|
||||
- **Metadata Wrapping:** Flexbox with `flex-wrap` for mobile
|
||||
|
||||
## Performance
|
||||
|
||||
- **Lazy State Management:** Only renders visible state (loading/error/success)
|
||||
- **Event Handlers:** Optimized with direct callbacks
|
||||
- **Image Loading:** Native browser lazy loading via `onLoad`/`onError`
|
||||
- **Transition:** CSS-only fade animation (no JavaScript)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Network Failures:** Catches `onerror` events
|
||||
- **User Retry:** Resets state and triggers re-render
|
||||
- **Clear Messaging:** Friendly error text with actionable button
|
||||
- **Visual Feedback:** Icon and color coding for quick recognition
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ExpandedImageViewProps {
|
||||
imageUrl: string;
|
||||
alt?: string;
|
||||
metadata?: {
|
||||
filename?: string;
|
||||
size?: string;
|
||||
dimensions?: string;
|
||||
};
|
||||
showMetadata?: boolean;
|
||||
}
|
||||
|
||||
export const ExpandedImageView = ({
|
||||
imageUrl,
|
||||
alt = 'Full size view',
|
||||
metadata,
|
||||
showMetadata = false
|
||||
}: ExpandedImageViewProps) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleLoad = () => setLoading(false);
|
||||
const handleError = () => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(false);
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 sm:p-6 md:p-8">
|
||||
{loading && !error && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<p className="text-gray-400 text-sm">Loading image...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="flex flex-col items-center gap-4 max-w-md text-center"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-red-900/20 border border-red-700/50 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Failed to Load Image</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
The image could not be loaded. Please check your connection and try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<div className="relative max-w-full max-h-full flex items-center justify-center">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
className={`max-w-full max-h-[calc(100vh-12rem)] object-contain rounded-lg shadow-2xl transition-opacity duration-300 ${
|
||||
loading ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMetadata && metadata && !loading && !error && (
|
||||
<div className="mt-4 px-4 py-3 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-400">
|
||||
{metadata.filename && (
|
||||
<span className="font-medium text-gray-300">{metadata.filename}</span>
|
||||
)}
|
||||
{metadata.dimensions && (
|
||||
<span>{metadata.dimensions}</span>
|
||||
)}
|
||||
{metadata.size && (
|
||||
<span>{metadata.size}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Footer Components
|
||||
|
||||
This directory contains two footer implementations for different contexts:
|
||||
|
||||
## Footer.tsx (Standard)
|
||||
**Use Case:** Full page layouts (homepage, documentation, etc.)
|
||||
- Larger horizontal logo (200px)
|
||||
- Expanded vertical spacing (pt-12 pb-4)
|
||||
- Center-aligned copyright at bottom
|
||||
- Full visual prominence
|
||||
|
||||
## CompactFooter.tsx (Compact)
|
||||
**Use Case:** Modal overlays, page provider system
|
||||
- Small square logo (32px desktop, 28px mobile)
|
||||
- Minimal height (h-16 on desktop)
|
||||
- Single-row horizontal layout (desktop)
|
||||
- 2-column grid layout (mobile)
|
||||
- Touch-optimized (min-h-[44px] on all links)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Standard Footer
|
||||
```tsx
|
||||
import { Footer } from '@/components/shared/Footer';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div>
|
||||
<main>{/* Page content */}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Compact Footer
|
||||
```tsx
|
||||
import { CompactFooter } from '@/components/shared/CompactFooter';
|
||||
|
||||
export default function ModalLayout() {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90">
|
||||
<main className="h-[calc(100vh-4rem)]">{/* Modal content */}</main>
|
||||
<CompactFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
Both components include:
|
||||
- Semantic `<footer>` with `role="contentinfo"`
|
||||
- `<nav>` with `aria-label="Footer navigation"`
|
||||
- WCAG 2.1 AA compliant touch targets (44px minimum)
|
||||
- Proper color contrast ratios (4.5:1+)
|
||||
- Keyboard navigation support
|
||||
- Descriptive alt text on logos
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
### CompactFooter
|
||||
- **Base (< 768px):** Vertical stack with 2-column link grid
|
||||
- **md (>= 768px):** Horizontal single-row layout
|
||||
|
||||
### Footer
|
||||
- **Base (< 768px):** Vertical stack with centered copyright
|
||||
- **md (>= 768px):** Horizontal layout with spaced sections
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { footerLinks, footerCopyright } from '@/config/footer';
|
||||
|
||||
export const Footer = () => {
|
||||
return (
|
||||
<footer className="relative z-10 border-t border-white/10 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-6 pt-12 pb-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<div className="h-16 flex items-center">
|
||||
<Image
|
||||
src="/banatie-logo-horisontal.png"
|
||||
alt="Banatie Logo"
|
||||
width={200}
|
||||
height={60}
|
||||
className="h-full w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-8 text-sm text-gray-400">
|
||||
{footerLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 text-center text-sm text-gray-500">{footerCopyright}</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ImageMetadataBar } from '../ImageMetadataBar';
|
||||
import { useImageDownloadTime } from './useImageDownloadTime';
|
||||
import { usePageContext } from '@/contexts/page-context';
|
||||
import { ExpandedImageView } from '../ExpandedImageView';
|
||||
|
||||
export interface ImageCardProps {
|
||||
imageUrl: string;
|
||||
|
|
@ -11,7 +13,6 @@ export interface ImageCardProps {
|
|||
height?: number;
|
||||
fileSize: number;
|
||||
fileType: string;
|
||||
onZoom: (url: string) => void;
|
||||
timestamp?: Date;
|
||||
className?: string;
|
||||
measureDownloadTime?: boolean;
|
||||
|
|
@ -25,26 +26,34 @@ export const ImageCard = ({
|
|||
height,
|
||||
fileSize,
|
||||
fileType,
|
||||
onZoom,
|
||||
timestamp,
|
||||
className = '',
|
||||
measureDownloadTime = false,
|
||||
onDownloadMeasured,
|
||||
}: ImageCardProps) => {
|
||||
const { openModal } = usePageContext();
|
||||
const { downloadTime, isLoading } = useImageDownloadTime(
|
||||
measureDownloadTime ? imageUrl : null
|
||||
);
|
||||
|
||||
// Track if we've already called onDownloadMeasured to prevent duplicate calls
|
||||
const hasCalledCallback = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (downloadTime !== null && onDownloadMeasured) {
|
||||
if (downloadTime !== null && onDownloadMeasured && !hasCalledCallback.current) {
|
||||
onDownloadMeasured(downloadTime);
|
||||
hasCalledCallback.current = true;
|
||||
}
|
||||
}, [downloadTime, onDownloadMeasured]);
|
||||
|
||||
const handleImageClick = () => {
|
||||
openModal(<ExpandedImageView imageUrl={imageUrl} alt={filename} />);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onZoom(imageUrl);
|
||||
handleImageClick();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -63,7 +72,7 @@ export const ImageCard = ({
|
|||
>
|
||||
<div
|
||||
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative"
|
||||
onClick={() => onZoom(imageUrl)}
|
||||
onClick={handleImageClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="View full size image"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
interface NarrowSectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
}
|
||||
|
||||
export const NarrowSection = ({ children, className = '', bgClassName = '' }: NarrowSectionProps) => {
|
||||
return (
|
||||
<div className={bgClassName}>
|
||||
<div className={`max-w-4xl mx-auto px-6 py-8 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
interface SectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
}
|
||||
|
||||
export const Section = ({ children, className = '', bgClassName = '' }: SectionProps) => {
|
||||
return (
|
||||
<div className={bgClassName}>
|
||||
<div className={`max-w-7xl mx-auto px-6 py-8 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
* Features:
|
||||
* - Dark nav bar with decorative wave line
|
||||
* - Active state indicator (purple color)
|
||||
* - Optional API Key input on the right
|
||||
* - Responsive (hamburger menu on mobile)
|
||||
* - Three-column layout matching docs structure
|
||||
* - Customizable left/right slots
|
||||
|
|
@ -19,13 +18,12 @@
|
|||
* <SubsectionNav
|
||||
* items={[...]}
|
||||
* currentPath="/docs/final"
|
||||
* showApiKeyInput={true}
|
||||
* leftSlot={<Logo />}
|
||||
* rightSlot={<UserMenu />}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, ReactNode } from 'react';
|
||||
import { useState, ReactNode } from 'react';
|
||||
import { ThreeColumnLayout } from '@/components/layout/ThreeColumnLayout';
|
||||
|
||||
interface NavItem {
|
||||
|
|
@ -39,212 +37,19 @@ interface SubsectionNavProps {
|
|||
ctaText?: string;
|
||||
ctaHref?: string;
|
||||
onCtaClick?: () => void;
|
||||
showApiKeyInput?: boolean;
|
||||
/** Optional content for left column (w-64, hidden until lg) */
|
||||
leftSlot?: ReactNode;
|
||||
/** Optional content for right column (w-56, hidden until xl). If not provided and showApiKeyInput is true, API key input is used. */
|
||||
/** Optional content for right column (w-56, hidden until xl) */
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
const API_KEY_STORAGE = 'banatie_docs_api_key';
|
||||
|
||||
export const SubsectionNav = ({
|
||||
items,
|
||||
currentPath,
|
||||
showApiKeyInput = false,
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
}: SubsectionNavProps) => {
|
||||
export const SubsectionNav = ({ items, currentPath, leftSlot, rightSlot }: SubsectionNavProps) => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [isApiKeyExpanded, setIsApiKeyExpanded] = useState(false);
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isActive = (href: string) => currentPath.startsWith(href);
|
||||
|
||||
// Load API key from localStorage
|
||||
useEffect(() => {
|
||||
if (showApiKeyInput) {
|
||||
const stored = localStorage.getItem(API_KEY_STORAGE);
|
||||
if (stored) setApiKey(stored);
|
||||
}
|
||||
}, [showApiKeyInput]);
|
||||
|
||||
// Handle API key changes
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
if (value) {
|
||||
localStorage.setItem(API_KEY_STORAGE, value);
|
||||
} else {
|
||||
localStorage.removeItem(API_KEY_STORAGE);
|
||||
}
|
||||
|
||||
// Dispatch event to notify widgets
|
||||
window.dispatchEvent(new CustomEvent('apiKeyChanged', { detail: value }));
|
||||
};
|
||||
|
||||
// Handle clear API key
|
||||
const handleClear = () => {
|
||||
setApiKey('');
|
||||
localStorage.removeItem(API_KEY_STORAGE);
|
||||
window.dispatchEvent(new CustomEvent('apiKeyChanged', { detail: '' }));
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsApiKeyExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isApiKeyExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isApiKeyExpanded]);
|
||||
|
||||
// Listen for custom event to expand API key from widgets
|
||||
useEffect(() => {
|
||||
const handleExpandApiKey = () => {
|
||||
setIsApiKeyExpanded(true);
|
||||
// Scroll to top to show nav
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
window.addEventListener('expandApiKeyInput', handleExpandApiKey);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('expandApiKeyInput', handleExpandApiKey);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Desktop API Key Input Component
|
||||
const apiKeyComponent = showApiKeyInput && (
|
||||
<div className="hidden md:block relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsApiKeyExpanded(!isApiKeyExpanded)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800/50 hover:bg-slate-800 border border-purple-500/30 hover:border-purple-500/50 rounded-lg transition-all"
|
||||
aria-label="Toggle API key input"
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
apiKey ? 'bg-green-400' : 'bg-amber-400'
|
||||
}`}
|
||||
></div>
|
||||
<span className="text-xs text-gray-300 font-medium">
|
||||
🔑 {apiKey ? 'API Key Set' : 'API Key'}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3 h-3 text-gray-400 transition-transform ${
|
||||
isApiKeyExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Card */}
|
||||
{isApiKeyExpanded && (
|
||||
<div className="absolute top-full right-0 mt-2 w-80 p-4 bg-slate-900/95 backdrop-blur-sm border border-purple-500/30 rounded-xl shadow-2xl z-50 animate-fade-in">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">API Key</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
Enter once, use across all examples
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsApiKeyExpanded(false)}
|
||||
className="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors"
|
||||
aria-label="Close API key input"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="text-xs text-gray-500 mb-1 block">Your API Key</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="Enter your API key"
|
||||
className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 rounded-lg text-sm text-gray-300 font-mono placeholder-gray-500 focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
className="px-3 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-gray-400 hover:text-white transition-colors"
|
||||
aria-label={keyVisible ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{keyVisible ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{apiKey && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="w-full px-3 py-2 bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 hover:border-red-600 rounded-lg text-red-400 text-xs font-medium transition-colors"
|
||||
>
|
||||
Clear API Key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
<p>Stored locally in your browser</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-40 bg-slate-950/80 backdrop-blur-sm border-b border-white/10">
|
||||
<nav className="sticky top-0 z-40 bg-slate-950/80 backdrop-blur-sm border-b border-white/10 relative">
|
||||
{/* Three-Column Layout */}
|
||||
<ThreeColumnLayout
|
||||
left={leftSlot}
|
||||
|
|
@ -253,60 +58,61 @@ export const SubsectionNav = ({
|
|||
<div className="flex items-center justify-between h-12">
|
||||
{/* Desktop Navigation Items */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
{items.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`
|
||||
{items.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`
|
||||
py-3 text-sm font-medium transition-colors
|
||||
${active ? 'text-purple-400' : 'text-gray-400 hover:text-white'}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="md:hidden flex items-center ml-auto">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="md:hidden flex items-center ml-auto">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{mobileMenuOpen ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
right={rightSlot || apiKeyComponent}
|
||||
/>
|
||||
|
||||
{/* Right Slot - Absolutely Positioned */}
|
||||
{rightSlot && (
|
||||
<div className="absolute top-0 right-0 h-12 flex items-center pr-0 hidden xl:flex overflow-visible">
|
||||
{rightSlot}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative Wave Line */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px overflow-hidden">
|
||||
<svg
|
||||
|
|
@ -335,82 +141,37 @@ export const SubsectionNav = ({
|
|||
{/* Mobile Menu Overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-white/10 bg-slate-900/95 backdrop-blur-sm">
|
||||
<div className="px-6 py-4 space-y-2">
|
||||
{items.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`
|
||||
block px-4 py-3 rounded-lg text-sm font-medium transition-colors
|
||||
${active ? 'bg-purple-500/10 text-purple-400' : 'text-gray-400 hover:text-white hover:bg-white/5'}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* API Key Input in Mobile Menu */}
|
||||
{showApiKeyInput && (
|
||||
<div className="pt-4 mt-4 border-t border-white/10">
|
||||
<h4 className="text-xs font-semibold text-white mb-2">API Key</h4>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Enter once, use across all examples
|
||||
</p>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="Enter your API key"
|
||||
className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 rounded-lg text-sm text-gray-300 font-mono placeholder-gray-500 focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
className="px-3 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-gray-400 hover:text-white transition-colors"
|
||||
aria-label={keyVisible ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{keyVisible ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="px-6 py-4">
|
||||
{/* ApiKeyWidget - Mobile */}
|
||||
{rightSlot && (
|
||||
<>
|
||||
<div className="flex justify-end mb-3">
|
||||
{rightSlot}
|
||||
</div>
|
||||
{apiKey && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="w-full px-3 py-2 bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 hover:border-red-600 rounded-lg text-red-400 text-xs font-medium transition-colors"
|
||||
>
|
||||
Clear API Key
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2">Stored locally in your browser</p>
|
||||
</div>
|
||||
{/* Visual separator */}
|
||||
<div className="border-t border-white/10 mb-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Navigation items */}
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`
|
||||
block px-4 py-3 rounded-lg text-sm font-medium transition-colors
|
||||
${active ? 'bg-purple-500/10 text-purple-400' : 'text-gray-400 hover:text-white hover:bg-white/5'}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
export const footerLinks = [
|
||||
{ label: 'Documentation', href: '#' },
|
||||
{ label: 'API Reference', href: '#' },
|
||||
{ label: 'Pricing', href: '#' },
|
||||
{ label: 'Contact', href: '#' },
|
||||
];
|
||||
|
||||
export const footerCopyright = '© 2025 Banatie. Built for builders who create.';
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { SubsectionNav } from '@/components/shared/SubsectionNav';
|
||||
import { CompactFooter } from '@/components/shared/CompactFooter';
|
||||
import { AnimatedBackground } from '@/components/shared/AnimatedBackground';
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type PageContextValue = {
|
||||
isOpen: boolean;
|
||||
openModal: (content: ReactNode) => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
type PageProviderProps = {
|
||||
navItems: NavItem[];
|
||||
currentPath: string;
|
||||
rightSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const PageContext = createContext<PageContextValue | null>(null);
|
||||
|
||||
export const usePageContext = () => {
|
||||
const context = useContext(PageContext);
|
||||
if (!context) {
|
||||
throw new Error('usePageContext must be used within PageProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const PageProvider = ({ navItems, currentPath, rightSlot, children }: PageProviderProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [modalContent, setModalContent] = useState<ReactNode | null>(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
const openModal = (content: ReactNode) => {
|
||||
setIsOpen(true);
|
||||
setModalContent(content);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
setModalContent(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
closeModal();
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const modalElement = document.querySelector('[data-modal-overlay]');
|
||||
if (!modalElement) return;
|
||||
|
||||
const focusableElements = modalElement.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey && document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleTab);
|
||||
return () => document.removeEventListener('keydown', handleTab);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const closeButton = document.querySelector(
|
||||
'[data-modal-overlay] button[aria-label="Close fullscreen view"]',
|
||||
) as HTMLElement;
|
||||
if (closeButton) {
|
||||
closeButton.focus();
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const contextValue = { isOpen, openModal, closeModal };
|
||||
|
||||
return (
|
||||
<PageContext.Provider value={contextValue}>
|
||||
<div aria-hidden={isOpen}>
|
||||
<AnimatedBackground />
|
||||
<SubsectionNav items={navItems} currentPath={currentPath} rightSlot={rightSlot} />
|
||||
<div className="relative z-10">{children}</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/90 backdrop-blur-sm transition-opacity duration-300"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
data-modal-overlay
|
||||
>
|
||||
<h2 id="modal-title" className="sr-only">
|
||||
Fullscreen Viewer
|
||||
</h2>
|
||||
|
||||
<div className="relative overflow-hidden">
|
||||
<SubsectionNav
|
||||
items={navItems}
|
||||
currentPath={currentPath}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-3 pr-6">
|
||||
<span className="hidden sm:block text-white/70 text-sm font-medium">
|
||||
Press ESC to close
|
||||
</span>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-black/90"
|
||||
aria-label="Close fullscreen view"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 min-h-0 overflow-auto" role="main" aria-label="Modal content">
|
||||
{modalContent}
|
||||
</main>
|
||||
|
||||
<CompactFooter />
|
||||
</div>
|
||||
)}
|
||||
</PageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
'use server';
|
||||
|
||||
import { listApiKeys as listApiKeysQuery } from '../db/queries/apiKeys';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
interface BootstrapResponse {
|
||||
|
|
@ -26,6 +24,48 @@ interface CreateKeyResponse {
|
|||
message: string;
|
||||
}
|
||||
|
||||
interface ListKeysApiResponse {
|
||||
keys: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
name: string | null;
|
||||
scopes: string[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
createdBy: string | null;
|
||||
organization: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
email: string;
|
||||
} | null;
|
||||
project: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ApiKeyListItem {
|
||||
id: string;
|
||||
keyType: string;
|
||||
name: string | null;
|
||||
scopes: string[];
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
lastUsedAt: Date | null;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
organizationEmail: string | null;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
}
|
||||
|
||||
export async function bootstrapMasterKey(): Promise<{
|
||||
success: boolean;
|
||||
apiKey?: string;
|
||||
|
|
@ -86,9 +126,45 @@ export async function createProjectApiKey(
|
|||
}
|
||||
}
|
||||
|
||||
export async function listApiKeys() {
|
||||
export async function listApiKeys(masterKey: string): Promise<ApiKeyListItem[]> {
|
||||
try {
|
||||
return await listApiKeysQuery();
|
||||
if (!masterKey) {
|
||||
console.error('Master key not provided');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/admin/keys`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': masterKey,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch API keys:', response.statusText);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data: ListKeysApiResponse = await response.json();
|
||||
|
||||
// Transform nested API response to flat structure for backwards compatibility
|
||||
return data.keys.map((key) => ({
|
||||
id: key.id,
|
||||
keyType: key.type,
|
||||
name: key.name,
|
||||
scopes: key.scopes,
|
||||
isActive: key.isActive,
|
||||
createdAt: new Date(key.createdAt),
|
||||
expiresAt: key.expiresAt ? new Date(key.expiresAt) : null,
|
||||
lastUsedAt: key.lastUsedAt ? new Date(key.lastUsedAt) : null,
|
||||
organizationId: key.organization?.id || null,
|
||||
organizationName: key.organization?.name || null,
|
||||
organizationEmail: key.organization?.email || null,
|
||||
projectId: key.project?.id || null,
|
||||
projectName: key.project?.name || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('List keys error:', error);
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
'use server';
|
||||
|
||||
import { getOrganizationByEmail, createOrganization } from '../db/queries/organizations';
|
||||
import { getProjectByName, createProject } from '../db/queries/projects';
|
||||
import type { Organization, Project } from '@banatie/database';
|
||||
|
||||
export async function getOrCreateOrganization(email: string, name: string): Promise<Organization> {
|
||||
// Try to find existing organization
|
||||
const existing = await getOrganizationByEmail(email);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new organization
|
||||
return createOrganization({ email, name });
|
||||
}
|
||||
|
||||
export async function getOrCreateProject(organizationId: string, name: string): Promise<Project> {
|
||||
// Try to find existing project
|
||||
const existing = await getProjectByName(organizationId, name);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new project
|
||||
return createProject({ organizationId, name });
|
||||
}
|
||||
|
||||
export async function getOrCreateOrgAndProject(
|
||||
email: string,
|
||||
orgName: string,
|
||||
projectName: string,
|
||||
): Promise<{ organization: Organization; project: Project }> {
|
||||
// Get or create organization
|
||||
const organization = await getOrCreateOrganization(email, orgName);
|
||||
|
||||
// Get or create project
|
||||
const project = await getOrCreateProject(organization.id, projectName);
|
||||
|
||||
return { organization, project };
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* API Key Management Constants
|
||||
*
|
||||
* Centralized constants for API key functionality across demo pages
|
||||
*/
|
||||
|
||||
/**
|
||||
* LocalStorage key for persisting API keys
|
||||
*/
|
||||
export const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||
|
||||
/**
|
||||
* Base URL for API requests
|
||||
* Uses environment variable or falls back to localhost
|
||||
*/
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* API endpoints
|
||||
*/
|
||||
export const API_ENDPOINTS = {
|
||||
INFO: '/api/info',
|
||||
TEXT_TO_IMAGE: '/api/text-to-image',
|
||||
UPLOAD: '/api/upload',
|
||||
IMAGES_GENERATED: '/api/images/generated',
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* API Key Management Library
|
||||
*
|
||||
* Centralized utilities for API key validation, storage, and management
|
||||
* Used across demo pages and components
|
||||
*/
|
||||
|
||||
// Constants
|
||||
export { API_KEY_STORAGE_KEY, API_BASE_URL, API_ENDPOINTS } from './constants';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ApiKeyInfo,
|
||||
ApiInfoResponse,
|
||||
ValidationResult,
|
||||
ApiKeyContextValue,
|
||||
ApiKeyProviderProps,
|
||||
} from './types';
|
||||
|
||||
// Validation utilities
|
||||
export { validateApiKeyRequest, parseKeyInfo } from './validation';
|
||||
|
||||
// Storage utilities
|
||||
export { getStoredKey, setStoredKey, clearStoredKey } from './storage';
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* API Key Storage Utilities
|
||||
*
|
||||
* localStorage utilities for persisting API keys
|
||||
*/
|
||||
|
||||
import { API_KEY_STORAGE_KEY } from './constants';
|
||||
|
||||
/**
|
||||
* Check if localStorage is available (browser environment)
|
||||
*/
|
||||
function isLocalStorageAvailable(): boolean {
|
||||
try {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored API key from localStorage
|
||||
*
|
||||
* @returns The stored API key or null if not found
|
||||
*/
|
||||
export function getStoredKey(): string | null {
|
||||
if (!isLocalStorageAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return localStorage.getItem(API_KEY_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error reading API key from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save API key to localStorage
|
||||
*
|
||||
* @param apiKey - The API key to store
|
||||
*/
|
||||
export function setStoredKey(apiKey: string): void {
|
||||
if (!isLocalStorageAvailable()) {
|
||||
console.warn('localStorage is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey);
|
||||
} catch (error) {
|
||||
console.error('Error saving API key to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove API key from localStorage
|
||||
*/
|
||||
export function clearStoredKey(): void {
|
||||
if (!isLocalStorageAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error removing API key from localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* API Key Management Types
|
||||
*
|
||||
* TypeScript interfaces and types for API key functionality
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Information about an API key extracted from validation response
|
||||
*/
|
||||
export interface ApiKeyInfo {
|
||||
organizationSlug?: string;
|
||||
projectSlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from API info endpoint
|
||||
*/
|
||||
export interface ApiInfoResponse {
|
||||
message?: string;
|
||||
keyInfo?: {
|
||||
organizationSlug?: string;
|
||||
organizationId?: string;
|
||||
projectSlug?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of API key validation
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
success: boolean;
|
||||
keyInfo?: ApiKeyInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value for API Key Provider
|
||||
*/
|
||||
export interface ApiKeyContextValue {
|
||||
// Data State
|
||||
apiKey: string;
|
||||
apiKeyValidated: boolean;
|
||||
apiKeyInfo: ApiKeyInfo | null;
|
||||
apiKeyError: string;
|
||||
validatingKey: boolean;
|
||||
isReady: boolean; // true when initial validation complete
|
||||
|
||||
// UI State (grouped)
|
||||
ui: {
|
||||
expanded: boolean;
|
||||
keyVisible: boolean;
|
||||
};
|
||||
|
||||
// Actions
|
||||
setApiKey: (key: string) => void;
|
||||
validateApiKey: () => Promise<void>;
|
||||
revokeApiKey: (clearPageState?: () => void) => void;
|
||||
toggleKeyVisibility: () => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
|
||||
// Focus method for external components
|
||||
focus: () => void;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for ApiKeyProvider component
|
||||
*/
|
||||
export interface ApiKeyProviderProps {
|
||||
children: React.ReactNode;
|
||||
onValidationSuccess?: (keyInfo: ApiKeyInfo) => void;
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* API Key Validation Logic
|
||||
*
|
||||
* Core validation functions for API key management
|
||||
*/
|
||||
|
||||
import { API_BASE_URL, API_ENDPOINTS } from './constants';
|
||||
import type { ApiKeyInfo, ApiInfoResponse, ValidationResult } from './types';
|
||||
|
||||
/**
|
||||
* Parse API key info from API response
|
||||
* Handles various response formats (organizationSlug vs organizationId)
|
||||
*/
|
||||
export function parseKeyInfo(data: ApiInfoResponse): ApiKeyInfo {
|
||||
if (data.keyInfo) {
|
||||
return {
|
||||
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId || 'Unknown',
|
||||
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
organizationSlug: 'Unknown',
|
||||
projectSlug: 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API key by making request to /api/info endpoint
|
||||
*
|
||||
* @param apiKey - The API key to validate
|
||||
* @returns ValidationResult with success status, keyInfo, or error
|
||||
*/
|
||||
export async function validateApiKeyRequest(apiKey: string): Promise<ValidationResult> {
|
||||
if (!apiKey.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Please enter an API key',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.INFO}`, {
|
||||
headers: {
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: ApiInfoResponse = await response.json();
|
||||
const keyInfo = parseKeyInfo(data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
keyInfo,
|
||||
};
|
||||
} else {
|
||||
// Try to parse error message from response
|
||||
try {
|
||||
const error = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Invalid API key',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: response.status === 401 ? 'Invalid API key' : `Request failed with status ${response.status}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API key validation error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to validate API key. Please check your connection.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from '@banatie/database';
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db';
|
||||
|
||||
// Create postgres client
|
||||
const client = postgres(connectionString);
|
||||
|
||||
// Create drizzle instance with schema
|
||||
export const db = drizzle(client, { schema });
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { db } from '../client';
|
||||
import { apiKeys, organizations, projects, type ApiKey } from '@banatie/database';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
export async function listApiKeys() {
|
||||
return db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
keyType: apiKeys.keyType,
|
||||
name: apiKeys.name,
|
||||
scopes: apiKeys.scopes,
|
||||
isActive: apiKeys.isActive,
|
||||
createdAt: apiKeys.createdAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
organizationId: apiKeys.organizationId,
|
||||
organizationName: organizations.name,
|
||||
organizationEmail: organizations.email,
|
||||
projectId: apiKeys.projectId,
|
||||
projectName: projects.name,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.leftJoin(organizations, eq(apiKeys.organizationId, organizations.id))
|
||||
.leftJoin(projects, eq(apiKeys.projectId, projects.id))
|
||||
.orderBy(desc(apiKeys.createdAt))
|
||||
.limit(50);
|
||||
}
|
||||
|
||||
export async function getApiKeysByProject(projectId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.projectId, projectId))
|
||||
.orderBy(desc(apiKeys.createdAt));
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { db } from '../client';
|
||||
import { organizations, type Organization, type NewOrganization } from '@banatie/database';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function getOrganizationByEmail(email: string): Promise<Organization | null> {
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(organizations)
|
||||
.where(eq(organizations.email, email))
|
||||
.limit(1);
|
||||
|
||||
return org || null;
|
||||
}
|
||||
|
||||
export async function createOrganization(data: NewOrganization): Promise<Organization> {
|
||||
const [org] = await db.insert(organizations).values(data).returning();
|
||||
|
||||
return org!;
|
||||
}
|
||||
|
||||
export async function listOrganizations(): Promise<Organization[]> {
|
||||
return db.select().from(organizations).orderBy(organizations.createdAt);
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { db } from '../client';
|
||||
import { projects, type Project, type NewProject } from '@banatie/database';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export async function getProjectByName(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
): Promise<Project | null> {
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.organizationId, organizationId), eq(projects.name, name)))
|
||||
.limit(1);
|
||||
|
||||
return project || null;
|
||||
}
|
||||
|
||||
export async function createProject(data: NewProject): Promise<Project> {
|
||||
const [project] = await db.insert(projects).values(data).returning();
|
||||
|
||||
return project!;
|
||||
}
|
||||
|
||||
export async function listProjectsByOrganization(organizationId: string): Promise<Project[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.organizationId, organizationId))
|
||||
.orderBy(projects.createdAt);
|
||||
}
|
||||
|
|
@ -0,0 +1,840 @@
|
|||
# Banatie REST API Implementation Plan
|
||||
|
||||
**Version:** 2.0
|
||||
**Status:** Ready for Implementation
|
||||
**Executor:** Claude Code
|
||||
**Database Schema:** v2.0 (banatie-database-design.md)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
REST API for Banatie image generation service. All endpoints use `/api/v1/` prefix for versioning.
|
||||
|
||||
**Core Features:**
|
||||
- AI image generation with Google Gemini Flash
|
||||
- Dual alias system (project-scoped + flow-scoped)
|
||||
- Technical aliases (@last, @first, @upload)
|
||||
- Flow-based generation chains
|
||||
- Live generation endpoint with caching
|
||||
- Upload and reference images
|
||||
|
||||
**Authentication:** API keys only (`bnt_` prefix)
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require API key in header:
|
||||
|
||||
```
|
||||
X-API-Key: bnt_xxx...
|
||||
```
|
||||
|
||||
**API Key Types:**
|
||||
- `master`: Full access to all projects in organization
|
||||
- `project`: Access to specific project only
|
||||
|
||||
**Unauthorized Response (401):**
|
||||
```json
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Invalid or missing API key"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Core utilities and services
|
||||
|
||||
**Tasks:**
|
||||
- Create TypeScript type definitions for all models
|
||||
- Build validation utilities (alias format, pagination, query params)
|
||||
- Build helper utilities (pagination, hash, query helpers)
|
||||
- Create `AliasService` with 3-tier resolution (technical → flow → project)
|
||||
|
||||
**Git Commit:**
|
||||
```
|
||||
feat: add foundation utilities and alias service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Core Generation Flow
|
||||
**Goal:** Main generation endpoints
|
||||
|
||||
**Services:**
|
||||
- `ImageService` - CRUD operations with soft delete
|
||||
- `GenerationService` - Full lifecycle management
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/v1/generations` - Create with reference images & dual aliases
|
||||
- `GET /api/v1/generations` - List with filters
|
||||
- `GET /api/v1/generations/:id` - Get details with related data
|
||||
|
||||
**Git Commit:**
|
||||
```
|
||||
feat: implement core generation endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Flow Management
|
||||
**Goal:** Flow operations
|
||||
|
||||
**Services:**
|
||||
- `FlowService` - CRUD with computed counts & alias management
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/v1/flows` - Create flow
|
||||
- `GET /api/v1/flows` - List flows with computed counts
|
||||
- `GET /api/v1/flows/:id` - Get details with generations and images
|
||||
- `PUT /api/v1/flows/:id/aliases` - Update flow aliases
|
||||
- `DELETE /api/v1/flows/:id/aliases/:alias` - Remove specific alias
|
||||
- `DELETE /api/v1/flows/:id` - Delete flow
|
||||
|
||||
**Git Commit:**
|
||||
```
|
||||
feat: implement flow management endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Enhanced Image Management
|
||||
**Goal:** Complete image operations
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/v1/images/upload` - Upload with alias, flow, metadata
|
||||
- `GET /api/v1/images` - List with filters
|
||||
- `GET /api/v1/images/:id` - Get details with usage info
|
||||
- `GET /api/v1/images/resolve/:alias` - Resolve alias with precedence
|
||||
- `PUT /api/v1/images/:id` - Update metadata
|
||||
- `DELETE /api/v1/images/:id` - Soft/hard delete
|
||||
|
||||
**Git Commit:**
|
||||
```
|
||||
feat: implement image management endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Generation Refinements
|
||||
**Goal:** Additional generation operations
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/v1/generations/:id/retry` - Retry failed generation
|
||||
- `DELETE /api/v1/generations/:id` - Delete generation
|
||||
|
||||
**Git Commit:**
|
||||
```
|
||||
feat: add generation retry and delete endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Live Generation
|
||||
**Goal:** URL-based generation with caching
|
||||
|
||||
**Services:**
|
||||
- `PromptCacheService` - SHA-256 caching with hit tracking
|
||||
|
||||
**Endpoints:**
|
||||
- `GET /api/v1/live` - Generate image via URL with streaming proxy
|
||||
|
||||
**Important:** Stream image directly from MinIO (no 302 redirect) for better performance.
|
||||
|
||||
**Git Commit:**
|
||||
```
|
||||
feat: implement live generation endpoint with caching
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Analytics
|
||||
**Goal:** Project statistics and metrics
|
||||
|
||||
**Services:**
|
||||
- `AnalyticsService` - Aggregation queries
|
||||
|
||||
**Endpoints:**
|
||||
- `GET /api/v1/analytics/summary` - Project statistics
|
||||
- `GET /api/v1/analytics/generations/timeline` - Time-series data
|
||||
|
||||
**Git Commit:**
|
||||
```
|
||||
feat: add analytics endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Testing & Documentation
|
||||
**Goal:** Quality assurance
|
||||
|
||||
**Tasks:**
|
||||
- Unit tests for all services (target >80% coverage)
|
||||
- Integration tests for critical flows
|
||||
- Error handling consistency review
|
||||
- Update API documentation
|
||||
|
||||
**Git Commit:**
|
||||
```
|
||||
test: add comprehensive test coverage and documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints Specification
|
||||
|
||||
### GENERATIONS
|
||||
|
||||
#### POST /api/v1/generations
|
||||
|
||||
Create new image generation.
|
||||
|
||||
**Request Body:**
|
||||
```typescript
|
||||
{
|
||||
prompt: string; // Required: 1-2000 chars
|
||||
aspectRatio?: string; // Optional: '16:9', '1:1', '4:3', '9:16'
|
||||
width?: number; // Optional: 1-8192
|
||||
height?: number; // Optional: 1-8192
|
||||
referenceImages?: string[]; // Optional: ['@logo', '@product', '@last']
|
||||
flowId?: string; // Optional: Add to existing flow
|
||||
assignAlias?: string; // Optional: Project-scoped alias '@brand'
|
||||
assignFlowAlias?: string; // Optional: Flow-scoped alias '@hero' (requires flowId)
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
generation: Generation;
|
||||
image?: Image; // If generation completed
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** 400, 401, 404, 422, 429, 500
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/generations
|
||||
|
||||
List generations with filtering.
|
||||
|
||||
**Query Params:**
|
||||
```typescript
|
||||
{
|
||||
flowId?: string;
|
||||
status?: 'pending' | 'processing' | 'success' | 'failed';
|
||||
limit?: number; // Default: 20, max: 100
|
||||
offset?: number; // Default: 0
|
||||
sortBy?: 'createdAt' | 'updatedAt';
|
||||
order?: 'asc' | 'desc'; // Default: desc
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
generations: Generation[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/generations/:id
|
||||
|
||||
Get generation details.
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
generation: Generation;
|
||||
image?: Image;
|
||||
referencedImages: Image[];
|
||||
flow?: FlowSummary;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/v1/generations/:id/retry
|
||||
|
||||
Retry failed generation.
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
generation: Generation; // New generation with incremented retry_count
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** 404, 422
|
||||
|
||||
---
|
||||
|
||||
#### DELETE /api/v1/generations/:id
|
||||
|
||||
Delete generation.
|
||||
|
||||
**Query Params:**
|
||||
```typescript
|
||||
{
|
||||
hard?: boolean; // Default: false
|
||||
}
|
||||
```
|
||||
|
||||
**Response (204):** No content
|
||||
|
||||
---
|
||||
|
||||
### IMAGES
|
||||
|
||||
#### POST /api/v1/images/upload
|
||||
|
||||
Upload image file.
|
||||
|
||||
**Request:** multipart/form-data
|
||||
|
||||
**Fields:**
|
||||
```typescript
|
||||
{
|
||||
file: File; // Required, max 5MB
|
||||
alias?: string; // Project-scoped: '@logo'
|
||||
flowAlias?: string; // Flow-scoped: '@hero' (requires flowId)
|
||||
flowId?: string;
|
||||
description?: string;
|
||||
tags?: string[]; // JSON array as string
|
||||
focalPoint?: string; // JSON: '{"x":0.5,"y":0.5}'
|
||||
meta?: string; // JSON object as string
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
```typescript
|
||||
{
|
||||
image: Image;
|
||||
flow?: FlowSummary; // If flowAlias assigned
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** 400, 409, 422
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/images
|
||||
|
||||
List images.
|
||||
|
||||
**Query Params:**
|
||||
```typescript
|
||||
{
|
||||
flowId?: string;
|
||||
source?: 'generated' | 'uploaded';
|
||||
alias?: string;
|
||||
limit?: number; // Default: 20, max: 100
|
||||
offset?: number;
|
||||
sortBy?: 'createdAt' | 'fileSize';
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
images: Image[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/images/:id
|
||||
|
||||
Get image details.
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
image: Image;
|
||||
generation?: Generation;
|
||||
usedInGenerations: GenerationSummary[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/images/resolve/:alias
|
||||
|
||||
Resolve alias to image.
|
||||
|
||||
**Query Params:**
|
||||
```typescript
|
||||
{
|
||||
flowId?: string; // Provide flow context
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
image: Image;
|
||||
scope: 'flow' | 'project' | 'technical';
|
||||
flow?: FlowSummary;
|
||||
}
|
||||
```
|
||||
|
||||
**Resolution Order:**
|
||||
1. Technical aliases (@last, @first, @upload) if flowId provided
|
||||
2. Flow aliases from flows.aliases if flowId provided
|
||||
3. Project aliases from images.alias
|
||||
|
||||
**Errors:** 404
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/v1/images/:id
|
||||
|
||||
Update image metadata.
|
||||
|
||||
**Request Body:**
|
||||
```typescript
|
||||
{
|
||||
alias?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
focalPoint?: { x: number; y: number };
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
image: Image;
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** 404, 409, 422
|
||||
|
||||
---
|
||||
|
||||
#### DELETE /api/v1/images/:id
|
||||
|
||||
Delete image.
|
||||
|
||||
**Query Params:**
|
||||
```typescript
|
||||
{
|
||||
hard?: boolean; // Default: false
|
||||
}
|
||||
```
|
||||
|
||||
**Response (204):** No content
|
||||
|
||||
---
|
||||
|
||||
### FLOWS
|
||||
|
||||
#### POST /api/v1/flows
|
||||
|
||||
Create new flow.
|
||||
|
||||
**Request Body:**
|
||||
```typescript
|
||||
{
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
```typescript
|
||||
{
|
||||
flow: Flow;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/flows
|
||||
|
||||
List flows.
|
||||
|
||||
**Query Params:**
|
||||
```typescript
|
||||
{
|
||||
limit?: number; // Default: 20, max: 100
|
||||
offset?: number;
|
||||
sortBy?: 'createdAt' | 'updatedAt';
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
flows: Array<Flow & {
|
||||
generationCount: number; // Computed
|
||||
imageCount: number; // Computed
|
||||
}>;
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/flows/:id
|
||||
|
||||
Get flow details.
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
flow: Flow;
|
||||
generations: Generation[]; // Ordered by created_at ASC
|
||||
images: Image[];
|
||||
resolvedAliases: Record<string, Image>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### PUT /api/v1/flows/:id/aliases
|
||||
|
||||
Update flow aliases.
|
||||
|
||||
**Request Body:**
|
||||
```typescript
|
||||
{
|
||||
aliases: Record<string, string>; // { "@hero": "image-uuid" }
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
flow: Flow;
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- Keys must match `^@[a-zA-Z0-9_-]+$`
|
||||
- Values must be valid image UUIDs
|
||||
- Cannot use reserved: @last, @first, @upload
|
||||
|
||||
**Errors:** 404, 422
|
||||
|
||||
---
|
||||
|
||||
#### DELETE /api/v1/flows/:id/aliases/:alias
|
||||
|
||||
Remove specific alias from flow.
|
||||
|
||||
**Response (204):** No content
|
||||
|
||||
**Errors:** 404
|
||||
|
||||
---
|
||||
|
||||
#### DELETE /api/v1/flows/:id
|
||||
|
||||
Delete flow.
|
||||
|
||||
**Response (204):** No content
|
||||
|
||||
**Note:** Cascades to images, sets NULL on generations.flow_id
|
||||
|
||||
---
|
||||
|
||||
### LIVE GENERATION
|
||||
|
||||
#### GET /api/v1/live
|
||||
|
||||
Generate image via URL with caching and streaming.
|
||||
|
||||
**Query Params:**
|
||||
```typescript
|
||||
{
|
||||
prompt: string; // Required
|
||||
aspectRatio?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
reference?: string | string[]; // '@logo' or ['@logo','@style']
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Image stream with headers
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Content-Type: image/jpeg
|
||||
Cache-Control: public, max-age=31536000
|
||||
X-Cache-Status: HIT | MISS
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
1. Compute cache key: SHA256(prompt + sorted params)
|
||||
2. Check prompt_url_cache table
|
||||
3. If HIT: increment hit_count, stream from MinIO
|
||||
4. If MISS: generate, cache, stream from MinIO
|
||||
5. Stream image bytes directly (no 302 redirect)
|
||||
|
||||
**Errors:** 400, 404, 500
|
||||
|
||||
---
|
||||
|
||||
### ANALYTICS
|
||||
|
||||
#### GET /api/v1/analytics/summary
|
||||
|
||||
Get project statistics.
|
||||
|
||||
**Query Params:**
|
||||
```typescript
|
||||
{
|
||||
startDate?: string; // ISO 8601
|
||||
endDate?: string;
|
||||
flowId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
period: { startDate: string; endDate: string };
|
||||
metrics: {
|
||||
totalGenerations: number;
|
||||
successfulGenerations: number;
|
||||
failedGenerations: number;
|
||||
successRate: number;
|
||||
totalImages: number;
|
||||
uploadedImages: number;
|
||||
generatedImages: number;
|
||||
avgProcessingTimeMs: number;
|
||||
totalCacheHits: number;
|
||||
cacheHitRate: number;
|
||||
totalCost: number;
|
||||
};
|
||||
flows: FlowSummary[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### GET /api/v1/analytics/generations/timeline
|
||||
|
||||
Get generation statistics over time.
|
||||
|
||||
**Query Params:**
|
||||
```typescript
|
||||
{
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
flowId?: string;
|
||||
groupBy?: 'hour' | 'day' | 'week'; // Default: day
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```typescript
|
||||
{
|
||||
data: Array<{
|
||||
timestamp: string;
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
avgProcessingTimeMs: number;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Alias Resolution Algorithm
|
||||
|
||||
**Priority Order:**
|
||||
1. Technical aliases (@last, @first, @upload) - compute from flow data
|
||||
2. Flow-scoped aliases - from flows.aliases JSONB
|
||||
3. Project-scoped aliases - from images.alias column
|
||||
|
||||
**Technical Aliases:**
|
||||
- `@last`: Latest generation output in flow (any status)
|
||||
- `@first`: First generation output in flow
|
||||
- `@upload`: Latest uploaded image in flow
|
||||
|
||||
### Dual Alias Assignment
|
||||
|
||||
When creating generation or uploading image:
|
||||
- `assignAlias` → set images.alias (project scope)
|
||||
- `assignFlowAlias` → add to flows.aliases (flow scope)
|
||||
- Both can be assigned simultaneously
|
||||
|
||||
### Flow Updates
|
||||
|
||||
Update `flows.updated_at` on:
|
||||
- New generation created with flowId
|
||||
- New image uploaded with flowId
|
||||
- Flow aliases modified
|
||||
|
||||
### Audit Trail
|
||||
|
||||
Track `api_key_id` in:
|
||||
- `images.api_key_id` - who uploaded/generated
|
||||
- `generations.api_key_id` - who requested
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
In-memory rate limiting (defer Redis for MVP):
|
||||
- Master key: 1000 req/hour, 100 generations/hour
|
||||
- Project key: 500 req/hour, 50 generations/hour
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
X-RateLimit-Limit: 500
|
||||
X-RateLimit-Remaining: 487
|
||||
X-RateLimit-Reset: 1698765432
|
||||
```
|
||||
|
||||
### Error Response Format
|
||||
|
||||
```typescript
|
||||
{
|
||||
error: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
requestId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### MinIO Integration
|
||||
|
||||
Use streaming for `/api/v1/live`:
|
||||
```typescript
|
||||
const stream = await minioClient.getObject(bucket, storageKey);
|
||||
res.set('Content-Type', mimeType);
|
||||
stream.pipe(res);
|
||||
```
|
||||
|
||||
Generate presigned URLs for other endpoints:
|
||||
```typescript
|
||||
const url = await minioClient.presignedGetObject(bucket, storageKey, 24 * 60 * 60);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
**Alias Format:**
|
||||
- Pattern: `^@[a-zA-Z0-9_-]+$`
|
||||
- Reserved: @last, @first, @upload
|
||||
- Length: 3-100 chars
|
||||
|
||||
**File Upload:**
|
||||
- Max size: 5MB
|
||||
- MIME types: image/jpeg, image/png, image/webp
|
||||
- Max dimensions: 8192x8192
|
||||
|
||||
**Prompt:**
|
||||
- Min: 1 char
|
||||
- Max: 2000 chars
|
||||
|
||||
**Aspect Ratio:**
|
||||
- Pattern: `^\d+:\d+$`
|
||||
- Examples: 16:9, 1:1, 4:3, 9:16
|
||||
|
||||
---
|
||||
|
||||
## Service Architecture
|
||||
|
||||
### Core Services
|
||||
|
||||
**AliasService:**
|
||||
- Resolve aliases with 3-tier precedence
|
||||
- Compute technical aliases
|
||||
- Validate alias format
|
||||
|
||||
**ImageService:**
|
||||
- CRUD operations
|
||||
- Soft delete support
|
||||
- Usage tracking
|
||||
|
||||
**GenerationService:**
|
||||
- Generation lifecycle
|
||||
- Status transitions
|
||||
- Error handling
|
||||
- Retry logic
|
||||
|
||||
**FlowService:**
|
||||
- Flow CRUD
|
||||
- Alias management
|
||||
- Computed counts
|
||||
|
||||
**PromptCacheService:**
|
||||
- Cache key computation (SHA-256)
|
||||
- Hit tracking
|
||||
- Cache lookup
|
||||
|
||||
**AnalyticsService:**
|
||||
- Aggregation queries
|
||||
- Time-series grouping
|
||||
|
||||
### Reusable Utilities
|
||||
|
||||
**Validators:**
|
||||
- Alias format
|
||||
- Pagination params
|
||||
- Query filters
|
||||
|
||||
**Helpers:**
|
||||
- Pagination builder
|
||||
- SHA-256 hashing
|
||||
- Query helpers
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
**Unit Tests:**
|
||||
- All services must have unit tests
|
||||
- Target coverage: >80%
|
||||
- Mock database calls
|
||||
|
||||
**Integration Tests:**
|
||||
- Critical flows end-to-end
|
||||
- Real database transactions
|
||||
- API endpoint testing with supertest
|
||||
|
||||
**Test Scenarios:**
|
||||
- Alias resolution precedence
|
||||
- Flow-scoped vs project-scoped aliases
|
||||
- Technical alias computation
|
||||
- Dual alias assignment
|
||||
- Cache hit/miss behavior
|
||||
- Error handling
|
||||
- Rate limiting
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ All endpoints functional per specification
|
||||
✅ >80% test coverage on services
|
||||
✅ Consistent error handling across all endpoints
|
||||
✅ All validation rules implemented
|
||||
✅ Rate limiting working
|
||||
✅ Documentation updated
|
||||
✅ Git commits after each phase
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 2.0*
|
||||
*Created: 2025-11-09*
|
||||
*Target: Claude Code Implementation*
|
||||
*Database Schema: v2.0*
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue