Compare commits
9 Commits
f080063746
...
691e472a2e
| Author | SHA1 | Date |
|---|---|---|
|
|
691e472a2e | |
|
|
55c8d23c1b | |
|
|
dd48d4e1a1 | |
|
|
eb11db753e | |
|
|
1b3a357b5d | |
|
|
b7bb37f2a7 | |
|
|
f942480fc8 | |
|
|
15f9dc3526 | |
|
|
5aef778fc5 |
|
|
@ -13,12 +13,11 @@ apps/*/dist/
|
||||||
apps/*/.next/
|
apps/*/.next/
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
# Environment files
|
# Environment files - KEEP .env FILES FOR TRACKING
|
||||||
.env
|
# We commit .env but ignore local overrides and secrets
|
||||||
.env.local
|
.env*.local
|
||||||
.env.development.local
|
secrets.env
|
||||||
.env.test.local
|
.env.production
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# IDE files
|
# IDE files
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
@ -73,7 +72,7 @@ jspm_packages/
|
||||||
.yarn-integrity
|
.yarn-integrity
|
||||||
|
|
||||||
# dotenv environment variables file
|
# dotenv environment variables file
|
||||||
.env
|
# .env
|
||||||
|
|
||||||
# Generated images and uploads
|
# Generated images and uploads
|
||||||
data/storage/
|
data/storage/
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
"--access-mode=unrestricted"
|
"--access-mode=unrestricted"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"DATABASE_URI": "postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db"
|
"DATABASE_URI": "postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mastra": {
|
"mastra": {
|
||||||
|
|
|
||||||
93
CLAUDE.md
93
CLAUDE.md
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
**IMPORTANT**: Always refer to [docs/environment.md](docs/environment.md) for detailed environment configuration, runtime modes, and known issues. Keep that document updated when making infrastructure or configuration changes.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Banatie is a comprehensive monorepo for AI-powered image generation platform featuring multiple applications:
|
Banatie is a comprehensive monorepo for AI-powered image generation platform featuring multiple applications:
|
||||||
|
|
@ -15,34 +17,37 @@ The project uses MinIO for object storage and PostgreSQL for data persistence, a
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
Use `docker compose` command for Docker services (v3 version).
|
The project has **two separate environments**:
|
||||||
|
|
||||||
### Infrastructure
|
### Development Mode (Local API + Docker Infrastructure)
|
||||||
|
|
||||||
- `docker compose up -d postgres minio storage-init` - Start database and storage services
|
From `apps/api-service/`:
|
||||||
- `docker compose up -d` - Start all services including the API app container
|
|
||||||
- `docker compose down` - Stop all services
|
```bash
|
||||||
|
pnpm dev # Start infrastructure + API with hot reload
|
||||||
|
pnpm infra:up # Start only infrastructure (postgres, minio)
|
||||||
|
pnpm infra:down # Stop infrastructure
|
||||||
|
pnpm infra:logs # View infrastructure logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Mode (All Services in Docker)
|
||||||
|
|
||||||
|
From `prod-env/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # Start all services
|
||||||
|
docker compose up -d --build # Rebuild and start
|
||||||
|
docker compose down # Stop all services
|
||||||
|
docker compose logs -f app # API logs
|
||||||
|
docker compose logs -f landing # Landing logs
|
||||||
|
```
|
||||||
|
|
||||||
### Monorepo Commands (from root)
|
### Monorepo Commands (from root)
|
||||||
|
|
||||||
- `pnpm dev` - Start all applications in development mode
|
|
||||||
- `pnpm dev:api` - Start API service only (`apps/api-service`)
|
|
||||||
- `pnpm dev:landing` - Start landing page only (`apps/landing`)
|
|
||||||
- `pnpm dev:studio` - Start studio platform only (`apps/studio`)
|
|
||||||
- `pnpm dev:admin` - Start admin dashboard only (`apps/admin`)
|
|
||||||
|
|
||||||
### Build & Production
|
|
||||||
|
|
||||||
- `pnpm build` - Build all applications
|
- `pnpm build` - Build all applications
|
||||||
- `pnpm build:api` - Build API service only
|
|
||||||
- `pnpm start:api` - Start API service in production mode
|
|
||||||
- `pnpm start:landing` - Start landing page in production mode
|
|
||||||
|
|
||||||
### Code Quality (runs across all apps)
|
|
||||||
|
|
||||||
- `pnpm lint` - Run ESLint on all applications
|
- `pnpm lint` - Run ESLint on all applications
|
||||||
- `pnpm typecheck` - Run TypeScript checking on all applications
|
- `pnpm typecheck` - Run TypeScript checking on all applications
|
||||||
- `pnpm test` - Run tests (currently API service only)
|
- `pnpm test` - Run tests
|
||||||
- `pnpm clean` - Clean all build outputs and dependencies
|
- `pnpm clean` - Clean all build outputs and dependencies
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
@ -53,13 +58,22 @@ Use `docker compose` command for Docker services (v3 version).
|
||||||
banatie-service/
|
banatie-service/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── api-service/ # Express.js REST API (TypeScript)
|
│ ├── api-service/ # Express.js REST API (TypeScript)
|
||||||
|
│ │ ├── docker-compose.yml # Dev infrastructure only
|
||||||
|
│ │ ├── .env # Dev config (localhost)
|
||||||
|
│ │ └── Dockerfile # Production build
|
||||||
│ ├── landing/ # Next.js landing page
|
│ ├── landing/ # Next.js landing page
|
||||||
|
│ │ └── Dockerfile # Production build
|
||||||
│ ├── studio/ # Next.js SaaS platform
|
│ ├── studio/ # Next.js SaaS platform
|
||||||
│ └── admin/ # Next.js admin dashboard
|
│ └── admin/ # Next.js admin dashboard
|
||||||
├── packages/
|
├── packages/
|
||||||
│ └── database/ # Shared database package (Drizzle ORM)
|
│ └── database/ # Shared database package (Drizzle ORM)
|
||||||
|
├── prod-env/ # Production environment
|
||||||
|
│ ├── docker-compose.yml # All services (api, landing, infra)
|
||||||
|
│ ├── .env # Prod config (Docker hostnames)
|
||||||
|
│ └── README.md # Deployment instructions
|
||||||
├── data/ # Docker volume data (postgres, minio)
|
├── data/ # Docker volume data (postgres, minio)
|
||||||
├── docker-compose.yml # Infrastructure services
|
├── docs/
|
||||||
|
│ └── environment.md # Environment configuration guide
|
||||||
├── pnpm-workspace.yaml # Workspace configuration
|
├── pnpm-workspace.yaml # Workspace configuration
|
||||||
└── package.json # Root workspace scripts
|
└── package.json # Root workspace scripts
|
||||||
```
|
```
|
||||||
|
|
@ -101,7 +115,7 @@ banatie-service/
|
||||||
### Storage & Data
|
### Storage & Data
|
||||||
|
|
||||||
- **MinIO**: Object storage for generated images and uploads (port 9000)
|
- **MinIO**: Object storage for generated images and uploads (port 9000)
|
||||||
- **PostgreSQL**: Database for API keys, user data, and metadata (port 5434)
|
- **PostgreSQL**: Database for API keys, user data, and metadata (port 5460)
|
||||||
- Database name: `banatie_db`
|
- Database name: `banatie_db`
|
||||||
- User: `banatie_user`
|
- User: `banatie_user`
|
||||||
- Tables: `api_keys`, `organizations`, `projects`, `users`, `images`, `upload_sessions`
|
- Tables: `api_keys`, `organizations`, `projects`, `users`, `images`, `upload_sessions`
|
||||||
|
|
@ -125,6 +139,8 @@ Key table: `api_keys`
|
||||||
|
|
||||||
## Environment Configuration
|
## Environment Configuration
|
||||||
|
|
||||||
|
**CRITICAL**: See [docs/environment.md](docs/environment.md) for complete configuration details, known issues, and solutions.
|
||||||
|
|
||||||
**Important**: We use TWO `.env` files with different purposes:
|
**Important**: We use TWO `.env` files with different purposes:
|
||||||
|
|
||||||
### Root `.env` (Docker Compose Infrastructure)
|
### Root `.env` (Docker Compose Infrastructure)
|
||||||
|
|
@ -136,13 +152,30 @@ Used by Docker Compose services (MinIO, Postgres, API container). Key difference
|
||||||
- `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` - MinIO admin credentials
|
- `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` - MinIO admin credentials
|
||||||
- All variables are passed to the app container via docker-compose.yml environment section
|
- All variables are passed to the app container via docker-compose.yml environment section
|
||||||
|
|
||||||
### API Service `.env` (Local Development Only)
|
### Development `.env` (`apps/api-service/.env`)
|
||||||
|
|
||||||
Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` locally:
|
Used when running `pnpm dev` locally (connects to Docker via port forwarding):
|
||||||
|
|
||||||
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db` (port-forwarded)
|
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db`
|
||||||
- `MINIO_ENDPOINT=localhost:9000` (port-forwarded)
|
- `MINIO_ENDPOINT=localhost:9000`
|
||||||
- **NOTE**: This file is excluded from Docker builds (see Dockerfile.mono)
|
- **NOTE**: This file is excluded from Docker builds
|
||||||
|
|
||||||
|
### Production `.env` (`prod-env/.env`)
|
||||||
|
|
||||||
|
Used when running `docker compose` in prod-env (internal Docker hostnames):
|
||||||
|
|
||||||
|
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@postgres:5432/banatie_db`
|
||||||
|
- `MINIO_ENDPOINT=minio:9000`
|
||||||
|
- `IS_DOCKER=true` environment variable is set
|
||||||
|
|
||||||
|
### Secrets (`secrets.env`)
|
||||||
|
|
||||||
|
Sensitive values stored separately (NOT in git):
|
||||||
|
|
||||||
|
- `GEMINI_API_KEY` - Required for image generation
|
||||||
|
- `MASTER_KEY`, `API_KEY` - Optional testing keys
|
||||||
|
|
||||||
|
See `secrets.env.example` in each directory for template.
|
||||||
|
|
||||||
### Required Environment Variables
|
### Required Environment Variables
|
||||||
|
|
||||||
|
|
@ -195,7 +228,7 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local
|
||||||
| Landing Page | 3001 | Public website |
|
| Landing Page | 3001 | Public website |
|
||||||
| Studio Platform | 3002 | SaaS application |
|
| Studio Platform | 3002 | SaaS application |
|
||||||
| Admin Dashboard | 3003 | Administration |
|
| Admin Dashboard | 3003 | Administration |
|
||||||
| PostgreSQL | 5434 | Database |
|
| PostgreSQL | 5460 | Database |
|
||||||
| MinIO API | 9000 | Object storage |
|
| MinIO API | 9000 | Object storage |
|
||||||
| MinIO Console | 9001 | Storage management |
|
| MinIO Console | 9001 | Storage management |
|
||||||
|
|
||||||
|
|
@ -275,4 +308,6 @@ curl -X POST http://localhost:3000/api/upload \
|
||||||
- ESLint configured with TypeScript and Prettier integration
|
- ESLint configured with TypeScript and Prettier integration
|
||||||
- Jest for testing with ts-jest preset (API service)
|
- Jest for testing with ts-jest preset (API service)
|
||||||
- Each app can be developed and deployed independently
|
- Each app can be developed and deployed independently
|
||||||
- **Docker**: Uses monorepo-aware Dockerfile (`Dockerfile.mono`) that includes workspace packages
|
- **Docker**: Uses monorepo-aware Dockerfiles in each app directory
|
||||||
|
- **Environment**: Two separate configs (dev: `apps/api-service/`, prod: `prod-env/`)
|
||||||
|
- **Secrets**: Stored in `secrets.env` (not tracked in git)
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ See individual app README files for specific environment variables.
|
||||||
| Landing Page | 3001 | Public website |
|
| Landing Page | 3001 | Public website |
|
||||||
| Studio Platform | 3002 | SaaS application |
|
| Studio Platform | 3002 | SaaS application |
|
||||||
| Admin Dashboard | 3003 | Administration |
|
| Admin Dashboard | 3003 | Administration |
|
||||||
| PostgreSQL | 5434 | Database |
|
| PostgreSQL | 5460 | Database |
|
||||||
| MinIO API | 9000 | Object storage |
|
| MinIO API | 9000 | Object storage |
|
||||||
| MinIO Console | 9001 | Storage admin |
|
| MinIO Console | 9001 | Storage admin |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
BANATIE_API_URL=http://localhost:3000
|
BANATIE_API_URL=http://localhost:3000
|
||||||
|
|
||||||
# Database Configuration (Direct admin access)
|
# Database Configuration (Direct admin access)
|
||||||
POSTGRES_URL=postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie
|
POSTGRES_URL=postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie
|
||||||
|
|
||||||
# MinIO Storage Configuration
|
# MinIO Storage Configuration
|
||||||
MINIO_ENDPOINT=http://localhost:9000
|
MINIO_ENDPOINT=http://localhost:9000
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ pnpm dev
|
||||||
BANATIE_API_URL=http://localhost:3000
|
BANATIE_API_URL=http://localhost:3000
|
||||||
|
|
||||||
# Database (Direct admin access)
|
# Database (Direct admin access)
|
||||||
POSTGRES_URL=postgresql://user:password@localhost:5434/banatie
|
POSTGRES_URL=postgresql://user:password@localhost:5460/banatie
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
MINIO_ENDPOINT=http://localhost:9000
|
MINIO_ENDPOINT=http://localhost:9000
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
|
# Database Configuration (connects to Docker via port forwarding)
|
||||||
|
DATABASE_URL=postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db
|
||||||
|
|
||||||
|
# IMPORTANT: Sensitive values should be in secrets.env (not tracked in git)
|
||||||
|
# See secrets.env.example for required variables like GEMINI_API_KEY
|
||||||
|
|
||||||
|
# MinIO Storage Configuration
|
||||||
|
STORAGE_TYPE=minio
|
||||||
|
MINIO_ENDPOINT=localhost:9000
|
||||||
|
MINIO_ACCESS_KEY=banatie_service
|
||||||
|
MINIO_SECRET_KEY=banatie_service_key_2024
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_BUCKET_NAME=banatie
|
||||||
|
MINIO_PUBLIC_URL=http://localhost:9000
|
||||||
|
|
||||||
|
# MinIO Admin Configuration (for initialization)
|
||||||
|
MINIO_ROOT_USER=banatie_admin
|
||||||
|
MINIO_ROOT_PASSWORD=banatie_storage_secure_key_2024
|
||||||
|
|
||||||
|
# Multi-tenancy Configuration
|
||||||
|
DEFAULT_ORG_ID=default
|
||||||
|
DEFAULT_PROJECT_ID=main
|
||||||
|
DEFAULT_USER_ID=system
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
API_BASE_URL=http://localhost:3000
|
||||||
|
PRESIGNED_URL_EXPIRY=86400
|
||||||
|
|
||||||
|
# File Upload Configuration
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
|
MAX_FILES=3
|
||||||
|
|
||||||
|
# Directory Configuration
|
||||||
|
RESULTS_DIR=./results
|
||||||
|
UPLOADS_DIR=./uploads/temp
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
LOG_LEVEL=info
|
||||||
|
TTI_LOG=logs/tti-log.md
|
||||||
|
ENH_LOG=logs/enhancing.md
|
||||||
|
|
||||||
|
# REMOVED: Sensitive keys moved to secrets.env
|
||||||
|
# MASTER_KEY, API_KEY, and GEMINI_API_KEY should be in secrets.env
|
||||||
|
|
@ -1,76 +1,90 @@
|
||||||
# Development stage - for docker-compose development with hot reload
|
# Multi-stage Dockerfile for API Service
|
||||||
FROM node:20-alpine AS development
|
|
||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install pnpm globally
|
# Install pnpm
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
# Copy package files for dependency installation
|
# Copy workspace configuration
|
||||||
COPY package*.json pnpm-lock.yaml ./
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Copy package.json files
|
||||||
|
COPY apps/api-service/package.json ./apps/api-service/
|
||||||
|
COPY packages/database/package.json ./packages/database/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Copy source code
|
# Stage 2: Builder
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Use development command with hot reload
|
|
||||||
CMD ["pnpm", "dev"]
|
|
||||||
|
|
||||||
# Builder stage - for production build
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install pnpm globally
|
# Install pnpm
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
# Copy package files
|
# Copy dependencies from deps stage
|
||||||
COPY package*.json pnpm-lock.yaml ./
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/apps/api-service/node_modules ./apps/api-service/node_modules
|
||||||
|
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||||
|
|
||||||
# Install all dependencies (including dev dependencies for build)
|
# Copy workspace files
|
||||||
RUN pnpm install --frozen-lockfile
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
# Copy source code
|
# Copy database package
|
||||||
COPY . .
|
COPY packages/database ./packages/database
|
||||||
|
|
||||||
# Build TypeScript to JavaScript
|
# Copy API service source (exclude .env - it's for local dev only)
|
||||||
|
COPY apps/api-service/package.json ./apps/api-service/
|
||||||
|
COPY apps/api-service/tsconfig.json ./apps/api-service/
|
||||||
|
COPY apps/api-service/src ./apps/api-service/src
|
||||||
|
|
||||||
|
# Set working directory to API service
|
||||||
|
WORKDIR /app/apps/api-service
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Production stage - optimized final image
|
# Stage 3: Production Runner
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create non-root user for security
|
# Install pnpm
|
||||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
# Install pnpm globally
|
ENV NODE_ENV=production
|
||||||
RUN npm install -g pnpm
|
|
||||||
|
|
||||||
# Copy package files
|
# Create non-root user
|
||||||
COPY --from=builder /app/package*.json ./
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 apiuser
|
||||||
|
|
||||||
|
# Copy workspace configuration
|
||||||
|
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||||
|
|
||||||
# Install only production dependencies
|
# Copy database package
|
||||||
RUN pnpm install --prod --frozen-lockfile
|
COPY --from=builder /app/packages/database ./packages/database
|
||||||
|
|
||||||
# Copy built application
|
# Copy built API service
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder --chown=apiuser:nodejs /app/apps/api-service/dist ./apps/api-service/dist
|
||||||
|
COPY --from=builder /app/apps/api-service/package.json ./apps/api-service/
|
||||||
|
|
||||||
# Create required directories and set ownership
|
# Copy node_modules for runtime
|
||||||
RUN mkdir -p logs && chown -R nodejs:nodejs /app
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/apps/api-service/node_modules ./apps/api-service/node_modules
|
||||||
|
COPY --from=builder /app/packages/database/node_modules ./packages/database/node_modules
|
||||||
|
|
||||||
# Switch to non-root user
|
# Create directories for logs and data
|
||||||
USER nodejs
|
RUN mkdir -p /app/apps/api-service/logs /app/results /app/uploads/temp
|
||||||
|
RUN chown -R apiuser:nodejs /app/apps/api-service/logs /app/results /app/uploads
|
||||||
|
|
||||||
|
USER apiuser
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Health check
|
WORKDIR /app/apps/api-service
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD node -e "require('http').request('http://localhost:3000/health', (res) => { \
|
|
||||||
process.exit(res.statusCode === 200 ? 0 : 1) \
|
|
||||||
}).on('error', () => process.exit(1)).end()"
|
|
||||||
|
|
||||||
# Start the application
|
# Run production build
|
||||||
CMD ["node", "dist/server.js"]
|
CMD ["node", "dist/server.js"]
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# Monorepo-aware Dockerfile for API Service
|
|
||||||
# Development stage - for docker-compose development with hot reload
|
|
||||||
FROM node:20-alpine AS development
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install pnpm globally
|
|
||||||
RUN npm install -g pnpm@10.11.0
|
|
||||||
|
|
||||||
# Copy workspace configuration from root
|
|
||||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
|
||||||
|
|
||||||
# Copy all workspace packages
|
|
||||||
COPY packages/ ./packages/
|
|
||||||
|
|
||||||
# Copy API service (exclude .env file - it's for local dev only)
|
|
||||||
COPY apps/api-service/package.json ./apps/api-service/
|
|
||||||
COPY apps/api-service/tsconfig.json ./apps/api-service/
|
|
||||||
COPY apps/api-service/src/ ./apps/api-service/src/
|
|
||||||
|
|
||||||
# Install all dependencies (workspace-aware)
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Set working directory to API service
|
|
||||||
WORKDIR /app/apps/api-service
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Use development command with hot reload
|
|
||||||
CMD ["pnpm", "dev"]
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# REST Client Environment Variables
|
||||||
|
# This file is read by VSCode REST Client extension
|
||||||
|
|
||||||
|
# Base URL
|
||||||
|
baseUrl=http://localhost:3000
|
||||||
|
|
||||||
|
# API Keys (update these with your actual keys)
|
||||||
|
masterKey=bnt_f056994b6ccc024d7d0dd9d37103997ba4e47ef52aa62478356ee4eda8aa73db
|
||||||
|
apiKey=bnt_71e7e16732ac5e21f597edc56e99e8c3696e713552ec9d1f44dfeffb2ef7c495
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
### Banatie API Tests
|
||||||
|
### VSCode REST Client Extension format
|
||||||
|
### https://marketplace.visualstudio.com/items?itemName=humao.rest-client
|
||||||
|
|
||||||
|
###
|
||||||
|
### Setup:
|
||||||
|
### 1. Update apiKey in _tests/.env file
|
||||||
|
### 2. Run "Get API Info" to extract org/project from response
|
||||||
|
### 3. Other requests will use those values automatically
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# PUBLIC ENDPOINTS (No Authentication)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### 1. Health Check
|
||||||
|
GET {{$dotenv baseUrl}}/health
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
### 2. API Info (No Auth)
|
||||||
|
GET {{$dotenv baseUrl}}/api/info
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
### 3. Get API Info (Extract org/project)
|
||||||
|
# @name getApiInfo
|
||||||
|
GET {{$dotenv baseUrl}}/api/info
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# ADMIN ENDPOINTS (Master Key Required)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### 4. List All API Keys
|
||||||
|
GET {{$dotenv baseUrl}}/api/admin/keys
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv masterKey}}
|
||||||
|
|
||||||
|
### 5. Create New Organization + Project + Project API Key
|
||||||
|
# Update the slugs and names below, then execute
|
||||||
|
# After execution, copy the returned apiKey to _tests/.env
|
||||||
|
POST {{$dotenv baseUrl}}/api/admin/keys
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv masterKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "project",
|
||||||
|
"organizationSlug": "acme-corp",
|
||||||
|
"organizationName": "Acme Corporation",
|
||||||
|
"projectSlug": "mobile-app",
|
||||||
|
"projectName": "Mobile App Project",
|
||||||
|
"name": "Mobile App API Key",
|
||||||
|
"expiresInDays": 90
|
||||||
|
}
|
||||||
|
|
||||||
|
### 6. Create Another Master Key (admin use)
|
||||||
|
POST {{$dotenv baseUrl}}/api/admin/keys
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv masterKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "master",
|
||||||
|
"name": "Secondary Master Key"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 7. Revoke an API Key
|
||||||
|
# Replace <keyId> with actual ID from "List All API Keys" response
|
||||||
|
# DELETE {{$dotenv baseUrl}}/api/admin/keys/<keyId>
|
||||||
|
# Content-Type: application/json
|
||||||
|
# X-API-Key: {{$dotenv masterKey}}
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# PROTECTED ENDPOINTS (Project Key Required)
|
||||||
|
# These use org/project from the API Info response
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### 8. Generate Image - Simple Sunset
|
||||||
|
# @name generateSunset
|
||||||
|
POST {{$dotenv baseUrl}}/api/text-to-image
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A beautiful sunset over mountains with orange and purple sky, dramatic clouds, peaceful atmosphere",
|
||||||
|
"filename": "sunset_{{$timestamp}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 9. Generate Image - Cyberpunk City
|
||||||
|
# @name generateCyberpunk
|
||||||
|
POST {{$dotenv baseUrl}}/api/text-to-image
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "A futuristic cyberpunk cityscape at night with neon lights, flying cars, holographic advertisements, rain-slicked streets, highly detailed, cinematic lighting",
|
||||||
|
"filename": "cyberpunk_{{$timestamp}}",
|
||||||
|
"aspectRatio": "16:9"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 10. Generate Image - Portrait
|
||||||
|
# @name generatePortrait
|
||||||
|
POST {{$dotenv baseUrl}}/api/text-to-image
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Professional portrait of a business person, confident expression, modern office background, natural lighting, high quality photography",
|
||||||
|
"filename": "portrait_{{$timestamp}}",
|
||||||
|
"aspectRatio": "9:16"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 11. Generate Image - Abstract Art
|
||||||
|
POST {{$dotenv baseUrl}}/api/text-to-image
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Abstract digital art with vibrant colors, flowing shapes, geometric patterns, modern artistic style",
|
||||||
|
"filename": "abstract_{{$timestamp}}",
|
||||||
|
"aspectRatio": "1:1"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 12. List Generated Images
|
||||||
|
# @name listImages
|
||||||
|
GET {{$dotenv baseUrl}}/api/images
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
### 13. List Images with Filters (limit 5, generated only)
|
||||||
|
GET {{$dotenv baseUrl}}/api/images?limit=5&category=generated
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
### 14. Upload Image File
|
||||||
|
# NOTE: Update the file path to an actual image on your system
|
||||||
|
# For example: < /projects/my-projects/banatie-service/results/some_image.png
|
||||||
|
POST {{$dotenv baseUrl}}/api/upload
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
Content-Disposition: form-data; name="file"; filename="uploaded-test.png"
|
||||||
|
Content-Type: image/png
|
||||||
|
|
||||||
|
< /projects/my-projects/banatie-service/results/banatie-party.png
|
||||||
|
------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# ERROR CASES (Testing Validation)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### 15. Text-to-Image WITHOUT API Key (Should return 401)
|
||||||
|
POST {{$dotenv baseUrl}}/api/text-to-image
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "This should fail without API key"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 16. Text-to-Image with INVALID API Key (Should return 401)
|
||||||
|
POST {{$dotenv baseUrl}}/api/text-to-image
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: invalid_key_12345
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "This should fail with invalid key"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 17. Text-to-Image WITHOUT Prompt (Should return 400)
|
||||||
|
POST {{$dotenv baseUrl}}/api/text-to-image
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"filename": "no_prompt_test"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 18. Text-to-Image with Empty Prompt (Should return 400)
|
||||||
|
POST {{$dotenv baseUrl}}/api/text-to-image
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "",
|
||||||
|
"filename": "empty_prompt"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 19. Admin Endpoint with Project Key (Should return 403)
|
||||||
|
GET {{$dotenv baseUrl}}/api/admin/keys
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
### 20. Create Master Key with Project Key (Should return 403)
|
||||||
|
POST {{$dotenv baseUrl}}/api/admin/keys
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "master",
|
||||||
|
"name": "This should fail - wrong key type"
|
||||||
|
}
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# RATE LIMITING & HEADERS
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
### 21. Check Rate Limit Headers
|
||||||
|
GET {{$dotenv baseUrl}}/api/info
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Key: {{$dotenv apiKey}}
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# USAGE NOTES
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# Workflow:
|
||||||
|
# =========
|
||||||
|
# 1. Update apiKey in _tests/.env with your project key
|
||||||
|
# 2. Run request #3 "Get API Info" - this validates the key and shows org/project
|
||||||
|
# 3. Run requests #8-11 to generate images
|
||||||
|
# 4. Run request #12 to list all generated images
|
||||||
|
# 5. Run request #14 to upload an image (update file path first)
|
||||||
|
# 6. Run requests #15-20 to test error handling
|
||||||
|
|
||||||
|
# Admin Workflow:
|
||||||
|
# ===============
|
||||||
|
# 1. masterKey in _tests/.env is already set
|
||||||
|
# 2. Run request #4 to list all existing keys
|
||||||
|
# 3. Run request #5 to create new org/project/key
|
||||||
|
# 4. Copy the returned apiKey from response
|
||||||
|
# 5. Update apiKey in _tests/.env
|
||||||
|
# 6. Continue with testing workflow above
|
||||||
|
|
||||||
|
# Expected Results:
|
||||||
|
# =================
|
||||||
|
# - Request #3: Returns keyInfo with organizationSlug, projectSlug
|
||||||
|
# - Requests #8-11: Return image URLs and metadata
|
||||||
|
# - Request #12: Returns array of generated images
|
||||||
|
# - Request #14: Returns upload confirmation
|
||||||
|
# - Requests #15-20: Return appropriate error codes (401, 400, 403)
|
||||||
|
|
||||||
|
# Useful Commands:
|
||||||
|
# ================
|
||||||
|
# View MinIO Console: http://localhost:9001
|
||||||
|
# Database access: psql -h localhost -p 5460 -U banatie_user -d banatie_db
|
||||||
|
# View logs: tail -f apps/api-service/api-dev.log
|
||||||
|
|
||||||
|
# Dynamic Variables:
|
||||||
|
# ==================
|
||||||
|
# {{$timestamp}} - Current Unix timestamp
|
||||||
|
# {{$randomInt}} - Random integer
|
||||||
|
# {{$guid}} - Random GUID
|
||||||
|
# Responses can be referenced: {{requestName.response.body.$.field}}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
## 6. ERROR HANDLING ENDPOINTS (4/4) ✅
|
||||||
|
# Test Case Expected Actual Status
|
||||||
|
15 No API Key 401 ❌ 200 с error ⚠️ Не правильный статус
|
||||||
|
16 Invalid API Key 401 ❌ 200 с error ⚠️ Не правильный статус
|
||||||
|
17 Missing Prompt 400 ❌ 200 с error ⚠️ Не правильный статус
|
||||||
|
19 Wrong Key Type 403 ❌ 200 с error ⚠️ Не правильный статус
|
||||||
|
Детали:
|
||||||
|
✅ Error messages корректные
|
||||||
|
⚠️ HTTP status codes всегда 200 (должны быть 401, 400, 403)
|
||||||
|
Примеры ответов:
|
||||||
|
// Test 15 - No API Key
|
||||||
|
{
|
||||||
|
"error": "Missing API key",
|
||||||
|
"message": "Provide your API key via X-API-Key header"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 16 - Invalid Key
|
||||||
|
{
|
||||||
|
"error": "Invalid API key",
|
||||||
|
"message": "The provided API key is invalid, expired, or revoked"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 17 - Missing Prompt
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Validation failed",
|
||||||
|
"message": "Prompt is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 19 - Wrong Key Type
|
||||||
|
{
|
||||||
|
"error": "Master key required",
|
||||||
|
"message": "This endpoint requires a master API key"
|
||||||
|
}
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
Endpoint /api/images не существует
|
||||||
|
В документации упоминается /api/images
|
||||||
|
Реально работает только /api/images/generated
|
||||||
|
Нужно: Обновить документацию или добавить endpoint
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Development Infrastructure
|
||||||
|
# This docker-compose.yml starts only infrastructure services (postgres, minio)
|
||||||
|
# The API service runs locally via `pnpm dev` for hot reload
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: banatie-postgres-dev
|
||||||
|
ports:
|
||||||
|
- "5460:5432"
|
||||||
|
volumes:
|
||||||
|
- ../../data/postgres:/var/lib/postgresql/data
|
||||||
|
- ../../scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql
|
||||||
|
networks:
|
||||||
|
- banatie-dev-network
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: banatie_db
|
||||||
|
POSTGRES_USER: banatie_user
|
||||||
|
POSTGRES_PASSWORD: banatie_secure_password
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U banatie_user -d banatie_db"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# MinIO Object Storage - SNMD Mode
|
||||||
|
minio:
|
||||||
|
image: quay.io/minio/minio:latest
|
||||||
|
container_name: banatie-storage-dev
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # S3 API
|
||||||
|
- "9001:9001" # Web Console
|
||||||
|
volumes:
|
||||||
|
# SNMD: 4 drives for full S3 compatibility
|
||||||
|
- ../../data/storage/drive1:/data1
|
||||||
|
- ../../data/storage/drive2:/data2
|
||||||
|
- ../../data/storage/drive3:/data3
|
||||||
|
- ../../data/storage/drive4:/data4
|
||||||
|
networks:
|
||||||
|
- banatie-dev-network
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: banatie_admin
|
||||||
|
MINIO_ROOT_PASSWORD: banatie_storage_secure_key_2024
|
||||||
|
MINIO_BROWSER_REDIRECT_URL: http://localhost:9001
|
||||||
|
MINIO_SERVER_URL: http://localhost:9000
|
||||||
|
MINIO_DOMAIN: localhost
|
||||||
|
command: server /data{1...4} --console-address ":9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# MinIO Storage Initialization
|
||||||
|
storage-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
container_name: banatie-storage-init-dev
|
||||||
|
networks:
|
||||||
|
- banatie-dev-network
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
echo 'Setting up MinIO alias...'
|
||||||
|
mc alias set storage http://minio:9000 banatie_admin banatie_storage_secure_key_2024
|
||||||
|
|
||||||
|
echo 'Creating main bucket...'
|
||||||
|
mc mb --ignore-existing storage/banatie
|
||||||
|
|
||||||
|
echo 'Creating service user...'
|
||||||
|
mc admin user add storage banatie_service banatie_service_key_2024
|
||||||
|
|
||||||
|
echo 'Attaching readwrite policy to service user...'
|
||||||
|
mc admin policy attach storage readwrite --user=banatie_service
|
||||||
|
|
||||||
|
echo 'Setting up lifecycle policy...'
|
||||||
|
cat > /tmp/lifecycle.json <<'LIFECYCLE'
|
||||||
|
{
|
||||||
|
"Rules": [
|
||||||
|
{
|
||||||
|
"ID": "temp-cleanup",
|
||||||
|
"Status": "Enabled",
|
||||||
|
"Filter": {
|
||||||
|
"Prefix": "temp/"
|
||||||
|
},
|
||||||
|
"Expiration": {
|
||||||
|
"Days": 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
LIFECYCLE
|
||||||
|
mc ilm import storage/banatie < /tmp/lifecycle.json
|
||||||
|
|
||||||
|
echo 'Storage initialization completed!'
|
||||||
|
echo 'Bucket: banatie'
|
||||||
|
echo 'Dev mode: API service will connect via localhost:9000'
|
||||||
|
exit 0
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
banatie-dev-network:
|
||||||
|
driver: bridge
|
||||||
|
|
@ -4,7 +4,9 @@
|
||||||
"description": "Nano Banana Image Generation Service - REST API for AI-powered image generation using Gemini Flash Image model",
|
"description": "Nano Banana Image Generation Service - REST API for AI-powered image generation using Gemini Flash Image model",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"infra:up": "cd ../.. && docker compose up -d postgres minio storage-init",
|
"infra:up": "docker compose up -d postgres minio storage-init",
|
||||||
|
"infra:down": "docker compose down",
|
||||||
|
"infra:logs": "docker compose logs -f",
|
||||||
"dev": "npm run infra:up && echo 'Logs will be saved to api-dev.log' && tsx --watch src/server.ts 2>&1 | tee api-dev.log",
|
"dev": "npm run infra:up && echo 'Logs will be saved to api-dev.log' && tsx --watch src/server.ts 2>&1 | tee api-dev.log",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# secrets.env.example
|
||||||
|
# Copy this file to secrets.env and fill with real values
|
||||||
|
# secrets.env is NOT tracked in git for security
|
||||||
|
|
||||||
|
# REQUIRED: Google Gemini API Key for image generation
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
|
# OPTIONAL: For testing purposes (usually generated via API bootstrap)
|
||||||
|
# These keys will be generated when you first run the API
|
||||||
|
# MASTER_KEY=will_be_generated_on_bootstrap
|
||||||
|
# API_KEY=will_be_generated_by_admin_endpoint
|
||||||
|
|
@ -1,8 +1,24 @@
|
||||||
import { createDbClient } from '@banatie/database';
|
import { createDbClient } from '@banatie/database';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
// Load .env from api-service directory only if exists and not in Docker
|
||||||
|
// In Docker (IS_DOCKER=true), environment variables are injected via docker-compose
|
||||||
|
const envPath = path.join(__dirname, '../.env');
|
||||||
|
if (existsSync(envPath) && !process.env['IS_DOCKER']) {
|
||||||
|
config({ path: envPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also load secrets.env if exists (for both dev and docker)
|
||||||
|
const secretsPath = path.join(__dirname, '../secrets.env');
|
||||||
|
if (existsSync(secretsPath)) {
|
||||||
|
config({ path: secretsPath });
|
||||||
|
}
|
||||||
|
|
||||||
const DATABASE_URL =
|
const DATABASE_URL =
|
||||||
process.env['DATABASE_URL'] ||
|
process.env['DATABASE_URL'] ||
|
||||||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
|
'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db';
|
||||||
|
|
||||||
export const db = createDbClient(DATABASE_URL);
|
export const db = createDbClient(DATABASE_URL);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { StorageFactory } from '../services/StorageFactory';
|
import { StorageFactory } from '../services/StorageFactory';
|
||||||
import { asyncHandler } from '../middleware/errorHandler';
|
import { asyncHandler } from '../middleware/errorHandler';
|
||||||
|
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
||||||
|
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
|
||||||
|
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
|
||||||
|
|
||||||
export const imagesRouter = Router();
|
export const imagesRouter = Router();
|
||||||
|
|
||||||
|
|
@ -135,3 +138,93 @@ imagesRouter.get(
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/images/generated
|
||||||
|
* List generated images for the authenticated project
|
||||||
|
*/
|
||||||
|
imagesRouter.get(
|
||||||
|
'/images/generated',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const requestId = req.requestId;
|
||||||
|
|
||||||
|
// Parse and validate query parameters
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 30, 1), 100);
|
||||||
|
const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0);
|
||||||
|
const prefix = (req.query.prefix as string) || undefined;
|
||||||
|
|
||||||
|
// Validate query parameters
|
||||||
|
if (isNaN(limit) || isNaN(offset)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid query parameters',
|
||||||
|
error: 'limit and offset must be valid numbers',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract org/project from validated API key
|
||||||
|
const orgId = req.apiKey?.organizationSlug || 'default';
|
||||||
|
const projectId = req.apiKey?.projectSlug!;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Listing generated images for org:${orgId}, project:${projectId}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get storage service instance
|
||||||
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
|
// List files in generated category
|
||||||
|
const allFiles = await storageService.listFiles(orgId, projectId, 'generated', prefix);
|
||||||
|
|
||||||
|
// Sort by lastModified descending (newest first)
|
||||||
|
allFiles.sort((a, b) => {
|
||||||
|
const dateA = a.lastModified ? new Date(a.lastModified).getTime() : 0;
|
||||||
|
const dateB = b.lastModified ? new Date(b.lastModified).getTime() : 0;
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
const total = allFiles.length;
|
||||||
|
const paginatedFiles = allFiles.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
// Map to response format with public URLs
|
||||||
|
const images = paginatedFiles.map((file) => ({
|
||||||
|
filename: file.filename,
|
||||||
|
url: storageService.getPublicUrl(orgId, projectId, 'generated', file.filename),
|
||||||
|
size: file.size,
|
||||||
|
contentType: file.contentType,
|
||||||
|
lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hasMore = offset + limit < total;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} generated images`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
images,
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
hasMore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${timestamp}] [${requestId}] Failed to list generated images:`, error);
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to list generated images',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
// Load environment variables FIRST before any other imports
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
// Explicitly load from api-service .env file (not root)
|
||||||
|
config({ path: resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
import { createApp, appConfig } from './app';
|
import { createApp, appConfig } from './app';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,51 @@ export class MinioStorageService implements StorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeFilename(filename: string): string {
|
private sanitizeFilename(filename: string): string {
|
||||||
// Remove dangerous characters and path traversal attempts
|
// Remove path traversal attempts FIRST from entire filename
|
||||||
return filename
|
let cleaned = filename.replace(/\.\./g, '').trim();
|
||||||
|
|
||||||
|
// Split filename and extension
|
||||||
|
const lastDotIndex = cleaned.lastIndexOf('.');
|
||||||
|
let baseName = lastDotIndex > 0 ? cleaned.substring(0, lastDotIndex) : cleaned;
|
||||||
|
const extension = lastDotIndex > 0 ? cleaned.substring(lastDotIndex) : '';
|
||||||
|
|
||||||
|
// Remove dangerous characters from base name
|
||||||
|
baseName = baseName
|
||||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
|
||||||
.replace(/\.\./g, '') // Remove path traversal
|
.trim();
|
||||||
.replace(/^\.+/, '') // Remove leading dots
|
|
||||||
.trim()
|
// Replace non-ASCII characters with ASCII equivalents or remove them
|
||||||
.substring(0, 255); // Limit length
|
// This prevents S3 signature mismatches with MinIO
|
||||||
|
baseName = baseName
|
||||||
|
.normalize('NFD') // Decompose combined characters (é -> e + ´)
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
|
||||||
|
.replace(/[^\x20-\x7E]/g, '_') // Replace any remaining non-ASCII with underscore
|
||||||
|
.replace(/[^\w\s\-_.]/g, '_') // Replace special chars (except word chars, space, dash, underscore, dot) with underscore
|
||||||
|
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||||
|
.replace(/_{2,}/g, '_') // Collapse multiple underscores
|
||||||
|
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
|
||||||
|
|
||||||
|
// Ensure we still have a valid base name
|
||||||
|
if (baseName.length === 0) {
|
||||||
|
baseName = 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize extension (remove only dangerous chars, keep the dot)
|
||||||
|
let sanitizedExt = extension
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
|
||||||
|
.replace(/[^\x20-\x7E]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
// Ensure extension starts with a dot and is reasonable
|
||||||
|
if (sanitizedExt && !sanitizedExt.startsWith('.')) {
|
||||||
|
sanitizedExt = '.' + sanitizedExt;
|
||||||
|
}
|
||||||
|
if (sanitizedExt.length > 10) {
|
||||||
|
sanitizedExt = sanitizedExt.substring(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = baseName + sanitizedExt;
|
||||||
|
return result.substring(0, 255); // Limit total length
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateFilePath(
|
private validateFilePath(
|
||||||
|
|
@ -150,9 +188,13 @@ export class MinioStorageService implements StorageService {
|
||||||
const uniqueFilename = this.generateUniqueFilename(filename);
|
const uniqueFilename = this.generateUniqueFilename(filename);
|
||||||
const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename);
|
const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename);
|
||||||
|
|
||||||
|
// Encode original filename to Base64 to safely store non-ASCII characters in metadata
|
||||||
|
const originalNameEncoded = Buffer.from(filename, 'utf-8').toString('base64');
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'X-Amz-Meta-Original-Name': filename,
|
'X-Amz-Meta-Original-Name': originalNameEncoded,
|
||||||
|
'X-Amz-Meta-Original-Name-Encoding': 'base64',
|
||||||
'X-Amz-Meta-Category': category,
|
'X-Amz-Meta-Category': category,
|
||||||
'X-Amz-Meta-Project': projectId,
|
'X-Amz-Meta-Project': projectId,
|
||||||
'X-Amz-Meta-Organization': orgId,
|
'X-Amz-Meta-Organization': orgId,
|
||||||
|
|
@ -407,11 +449,16 @@ export class MinioStorageService implements StorageService {
|
||||||
etag: metadata.etag,
|
etag: metadata.etag,
|
||||||
path: obj.name,
|
path: obj.name,
|
||||||
});
|
});
|
||||||
} catch (error) {}
|
} catch (error) {
|
||||||
|
console.error('[MinIO listFiles] Error processing file:', obj.name, error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('end', () => resolve(files));
|
stream.on('end', () => resolve(files));
|
||||||
stream.on('error', reject);
|
stream.on('error', (error) => {
|
||||||
|
console.error('[MinIO listFiles] Stream error:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ export class StorageFactory {
|
||||||
const bucketName = process.env['MINIO_BUCKET_NAME'] || 'banatie';
|
const bucketName = process.env['MINIO_BUCKET_NAME'] || 'banatie';
|
||||||
const publicUrl = process.env['MINIO_PUBLIC_URL'];
|
const publicUrl = process.env['MINIO_PUBLIC_URL'];
|
||||||
|
|
||||||
|
console.log(`[StorageFactory] Creating MinIO client with endpoint: ${endpoint}`);
|
||||||
|
|
||||||
if (!endpoint || !accessKey || !secretKey) {
|
if (!endpoint || !accessKey || !secretKey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'MinIO configuration missing. Required: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY',
|
'MinIO configuration missing. Required: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Multi-stage Dockerfile for Next.js Landing Page
|
||||||
|
|
||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
|
# Copy workspace configuration
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Copy landing package.json
|
||||||
|
COPY apps/landing/package.json ./apps/landing/
|
||||||
|
|
||||||
|
# Copy database package (workspace dependency)
|
||||||
|
COPY packages/database/package.json ./packages/database/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Stage 2: Builder
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
|
# Copy dependencies from deps stage
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/apps/landing/node_modules ./apps/landing/node_modules
|
||||||
|
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||||
|
|
||||||
|
# Copy workspace files
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Copy database package
|
||||||
|
COPY packages/database ./packages/database
|
||||||
|
|
||||||
|
# Copy landing app
|
||||||
|
COPY apps/landing ./apps/landing
|
||||||
|
|
||||||
|
# Set working directory to landing
|
||||||
|
WORKDIR /app/apps/landing
|
||||||
|
|
||||||
|
# Build Next.js application
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 3: Production Runner
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10.11.0
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy workspace configuration
|
||||||
|
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Copy database package
|
||||||
|
COPY --from=builder /app/packages/database ./packages/database
|
||||||
|
|
||||||
|
# Copy built Next.js application
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/landing/.next ./apps/landing/.next
|
||||||
|
COPY --from=builder /app/apps/landing/package.json ./apps/landing/
|
||||||
|
COPY --from=builder /app/apps/landing/public ./apps/landing/public
|
||||||
|
|
||||||
|
# Copy node_modules for runtime
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/apps/landing/node_modules ./apps/landing/node_modules
|
||||||
|
COPY --from=builder /app/packages/database/node_modules ./packages/database/node_modules
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
WORKDIR /app/apps/landing
|
||||||
|
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||||
|
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
|
||||||
|
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 = {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
lastModified: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImagesResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
images: ImageItem[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
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);
|
||||||
|
|
||||||
|
const [images, setImages] = useState<ImageItem[]>([]);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchImages = async (keyToUse: string, fetchOffset: number) => {
|
||||||
|
if (fetchOffset === 0) {
|
||||||
|
setLoading(true);
|
||||||
|
} else {
|
||||||
|
setLoadingMore(true);
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/api/images/generated?limit=${IMAGES_PER_PAGE}&offset=${fetchOffset}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': keyToUse,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData: ImagesResponse = await response.json();
|
||||||
|
throw new Error(errorData.error || errorData.message || 'Failed to fetch images');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ImagesResponse = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const { images: newImages, offset: newOffset, hasMore: newHasMore } = result.data;
|
||||||
|
|
||||||
|
if (fetchOffset === 0) {
|
||||||
|
setImages(newImages);
|
||||||
|
} else {
|
||||||
|
setImages((prev) => [...prev, ...newImages]);
|
||||||
|
}
|
||||||
|
setOffset(newOffset);
|
||||||
|
setHasMore(newHasMore);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to fetch images');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to load images');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
const newOffset = offset + IMAGES_PER_PAGE;
|
||||||
|
fetchImages(apiKey, newOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadMeasured = useCallback((imageId: string, downloadMs: number) => {
|
||||||
|
setDownloadTimes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[imageId]: downloadMs,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 text-base md:text-lg">
|
||||||
|
Browse your AI-generated images
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!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>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
{validatingKey ? 'Validating...' : 'Validate'}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiKeyValidated && (
|
||||||
|
<section aria-label="Image Gallery">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16" role="status" aria-live="polite">
|
||||||
|
<div className="animate-spin w-12 h-12 border-4 border-amber-600 border-t-transparent rounded-full mb-4" aria-hidden="true"></div>
|
||||||
|
<p className="text-gray-400">Loading images...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-6 bg-red-900/20 border border-red-700/50 rounded-2xl" role="alert" aria-live="assertive">
|
||||||
|
<p className="text-red-400 font-medium mb-2">{error}</p>
|
||||||
|
<p className="text-sm text-red-300/80">
|
||||||
|
{error.includes('fetch') || error.includes('load')
|
||||||
|
? 'Unable to load images. Please check your connection and try refreshing the page.'
|
||||||
|
: 'An error occurred while fetching your images. Please try again later.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<EmptyGalleryState />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ImageGrid
|
||||||
|
images={images}
|
||||||
|
onImageZoom={setZoomedImageUrl}
|
||||||
|
onDownloadMeasured={handleDownloadMeasured}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center mt-8">
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="px-8 py-4 rounded-xl bg-gradient-to-r from-amber-600 to-orange-600 text-white font-semibold hover:from-amber-500 hover:to-orange-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-amber-900/30 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-slate-950 min-h-[44px]"
|
||||||
|
aria-busy={loadingMore}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{loadingMore ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" aria-hidden="true"></div>
|
||||||
|
Loading more images...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Load More Images'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ImageZoomModal imageUrl={zoomedImageUrl} onClose={() => setZoomedImageUrl(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||||
import { GenerationTimer } from '@/components/demo/GenerationTimer';
|
import { GenerationTimer } from '@/components/demo/GenerationTimer';
|
||||||
import { ResultCard } from '@/components/demo/ResultCard';
|
import { ResultCard } from '@/components/demo/ResultCard';
|
||||||
import { AdvancedOptionsModal, AdvancedOptionsData } from '@/components/demo/AdvancedOptionsModal';
|
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_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||||
|
|
@ -621,29 +622,7 @@ export default function DemoTTIPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Zoom Modal */}
|
{/* Zoom Modal */}
|
||||||
{zoomedImage && (
|
<ImageZoomModal imageUrl={zoomedImage} onClose={() => setZoomedImage(null)} />
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
|
|
||||||
onClick={() => setZoomedImage(null)}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Zoomed image view"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setZoomedImage(null)}
|
|
||||||
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors focus:ring-2 focus:ring-white"
|
|
||||||
aria-label="Close zoomed image"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
<img
|
|
||||||
src={zoomedImage}
|
|
||||||
alt="Zoomed"
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
import { useState, useEffect, useRef, DragEvent, ChangeEvent } from 'react';
|
import { useState, useEffect, useRef, DragEvent, ChangeEvent } from 'react';
|
||||||
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||||
|
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_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||||
|
|
@ -34,6 +40,10 @@ interface UploadHistoryItem {
|
||||||
size: number;
|
size: number;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
aspectRatio?: string;
|
||||||
|
downloadMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiKeyInfo {
|
interface ApiKeyInfo {
|
||||||
|
|
@ -57,10 +67,21 @@ export default function DemoUploadPage() {
|
||||||
const [uploadError, setUploadError] = useState('');
|
const [uploadError, setUploadError] = useState('');
|
||||||
const [validationError, setValidationError] = useState('');
|
const [validationError, setValidationError] = useState('');
|
||||||
const [dragActive, setDragActive] = useState(false);
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [imageDimensions, setImageDimensions] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
aspectRatio: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// History State
|
// History State
|
||||||
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
||||||
|
|
||||||
|
// Zoom Modal State
|
||||||
|
const [zoomedImageUrl, setZoomedImageUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Copy Feedback State
|
||||||
|
const [codeCopied, setCodeCopied] = useState(false);
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -210,6 +231,7 @@ export default function DemoUploadPage() {
|
||||||
setValidationError(error);
|
setValidationError(error);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
|
setImageDimensions(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,7 +239,20 @@ export default function DemoUploadPage() {
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
setPreviewUrl(reader.result as string);
|
const dataUrl = reader.result as string;
|
||||||
|
setPreviewUrl(dataUrl);
|
||||||
|
|
||||||
|
// Extract image dimensions
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const aspectRatio = calculateAspectRatio(img.width, img.height);
|
||||||
|
setImageDimensions({
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
aspectRatio,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
img.src = dataUrl;
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
@ -291,12 +326,16 @@ export default function DemoUploadPage() {
|
||||||
size: result.data.size,
|
size: result.data.size,
|
||||||
contentType: result.data.contentType,
|
contentType: result.data.contentType,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
width: imageDimensions?.width,
|
||||||
|
height: imageDimensions?.height,
|
||||||
|
aspectRatio: imageDimensions?.aspectRatio,
|
||||||
};
|
};
|
||||||
|
|
||||||
setUploadHistory((prev) => [historyItem, ...prev]);
|
setUploadHistory((prev) => [historyItem, ...prev]);
|
||||||
|
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
|
setImageDimensions(null);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
|
@ -310,15 +349,61 @@ export default function DemoUploadPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
const handleDownloadMeasured = (itemId: string, downloadMs: number) => {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
setUploadHistory((prev) =>
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
prev.map((item) =>
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
item.id === itemId ? { ...item, downloadMs } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (ms: number): string => {
|
const generateUploadCodeExamples = (item: UploadHistoryItem, key: string, baseUrl: string) => {
|
||||||
if (ms < 1000) return `${ms}ms`;
|
const fileName = item.originalName;
|
||||||
return `${(ms / 1000).toFixed(2)}s`;
|
|
||||||
|
return {
|
||||||
|
curl: `# Navigate to your images folder
|
||||||
|
cd /your/images/folder
|
||||||
|
|
||||||
|
# Upload the file
|
||||||
|
curl -X POST "${baseUrl}/api/upload" \\
|
||||||
|
-H "X-API-Key: ${key}" \\
|
||||||
|
-F "file=@${fileName}"`,
|
||||||
|
fetch: `// Set your images folder path
|
||||||
|
const imagePath = '/your/images/folder';
|
||||||
|
const fileName = '${fileName}';
|
||||||
|
|
||||||
|
// For Node.js with fs module:
|
||||||
|
const fs = require('fs');
|
||||||
|
const FormData = require('form-data');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fs.createReadStream(\`\${imagePath}/\${fileName}\`));
|
||||||
|
|
||||||
|
fetch('${baseUrl}/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': '${key}'
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => console.log(data))
|
||||||
|
.catch(error => console.error('Error:', error));`,
|
||||||
|
rest: `# From your images folder: /your/images/folder
|
||||||
|
|
||||||
|
POST ${baseUrl}/api/upload
|
||||||
|
Headers:
|
||||||
|
X-API-Key: ${key}
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
Body (form-data):
|
||||||
|
file: @${fileName}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyCode = (code: string) => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCodeCopied(true);
|
||||||
|
setTimeout(() => setCodeCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -475,14 +560,23 @@ export default function DemoUploadPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-white font-medium">{selectedFile.name}</p>
|
<p className="text-white font-medium mb-2">{selectedFile.name}</p>
|
||||||
<p className="text-sm text-gray-400">{formatFileSize(selectedFile.size)}</p>
|
{imageDimensions && (
|
||||||
|
<ImageMetadataBar
|
||||||
|
width={imageDimensions.width}
|
||||||
|
height={imageDimensions.height}
|
||||||
|
fileSize={selectedFile.size}
|
||||||
|
fileType={selectedFile.type}
|
||||||
|
showVisualIndicator={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
|
setImageDimensions(null);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
|
@ -501,6 +595,17 @@ export default function DemoUploadPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedFile && apiKeyValidated && !validationError && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<SelectedFileCodePreview
|
||||||
|
file={selectedFile}
|
||||||
|
apiKey={apiKey}
|
||||||
|
apiBaseUrl={API_BASE_URL}
|
||||||
|
onCopy={handleCopyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap pt-4 mt-4 border-t border-slate-700/50">
|
<div className="flex items-center justify-between gap-4 flex-wrap pt-4 mt-4 border-t border-slate-700/50">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{uploading ? 'Uploading...' : selectedFile ? 'Ready to upload' : 'No file selected'}
|
{uploading ? 'Uploading...' : selectedFile ? 'Ready to upload' : 'No file selected'}
|
||||||
|
|
@ -522,52 +627,43 @@ export default function DemoUploadPage() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{uploadHistory.length > 0 && (
|
{uploadHistory.length > 0 && (
|
||||||
<section className="space-y-4" aria-label="Upload History">
|
<section className="space-y-6" aria-label="Upload History">
|
||||||
<h2 className="text-xl md:text-2xl font-bold text-white">Upload History</h2>
|
<h2 className="text-xl md:text-2xl font-bold text-white">Upload History</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="space-y-6">
|
||||||
{uploadHistory.map((item) => (
|
{uploadHistory.map((item) => (
|
||||||
<div
|
<div key={item.id} className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
key={item.id}
|
{/* Column 1: Image Card */}
|
||||||
className="p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl hover:border-slate-600 transition-all"
|
<div className="lg:col-span-1">
|
||||||
>
|
<ImageCard
|
||||||
<div className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden">
|
imageUrl={item.url}
|
||||||
<img
|
filename={item.originalName}
|
||||||
src={item.url}
|
width={item.width}
|
||||||
alt={item.originalName}
|
height={item.height}
|
||||||
className="w-full h-full object-cover"
|
fileSize={item.size}
|
||||||
|
fileType={item.contentType}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
onZoom={setZoomedImageUrl}
|
||||||
|
measureDownloadTime={true}
|
||||||
|
onDownloadMeasured={(downloadMs) => handleDownloadMeasured(item.id, downloadMs)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-white text-sm font-medium truncate" title={item.originalName}>
|
{/* Columns 2-3: API Code Examples Widget */}
|
||||||
{item.originalName}
|
<div className="lg:col-span-2">
|
||||||
</p>
|
<CodeExamplesWidget
|
||||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
codeExamples={generateUploadCodeExamples(item, apiKey, API_BASE_URL)}
|
||||||
<span>{formatFileSize(item.size)}</span>
|
onCopy={handleCopyCode}
|
||||||
<span>{formatDuration(item.durationMs)}</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{item.timestamp.toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="mt-3 block text-center px-3 py-1.5 text-xs text-amber-400 hover:text-amber-300 border border-amber-600/30 hover:border-amber-500/50 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
View Full Image
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Image Zoom Modal */}
|
||||||
|
<ImageZoomModal imageUrl={zoomedImageUrl} onClose={() => setZoomedImageUrl(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production-ready code examples widget.
|
||||||
|
*
|
||||||
|
* Core Principle: All code snippets are designed to be COPY-PASTE-AND-RUN ready.
|
||||||
|
* Users should be able to copy the code, replace the placeholder folder path,
|
||||||
|
* and execute it immediately on their machine with minimal edits.
|
||||||
|
*
|
||||||
|
* Pattern Used:
|
||||||
|
* - Navigate to folder (with placeholder path: /your/images/folder)
|
||||||
|
* - Use actual filename from selected/uploaded file
|
||||||
|
* - Clear comments showing what to replace
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // User has file at: /home/user/Downloads/sunset.jpg
|
||||||
|
* // Code shows: cd /your/images/folder; curl ... -F "file=@sunset.jpg"
|
||||||
|
* // User replaces: /your/images/folder → /home/user/Downloads
|
||||||
|
* // Result: Works immediately after one simple edit!
|
||||||
|
*/
|
||||||
|
|
||||||
|
type CodeTab = 'curl' | 'fetch' | 'rest';
|
||||||
|
|
||||||
|
interface CodeExamplesWidgetProps {
|
||||||
|
codeExamples: {
|
||||||
|
curl: string;
|
||||||
|
fetch: string;
|
||||||
|
rest: string;
|
||||||
|
};
|
||||||
|
onCopy: (text: string) => void;
|
||||||
|
defaultTab?: CodeTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeExamplesWidget = ({
|
||||||
|
codeExamples,
|
||||||
|
onCopy,
|
||||||
|
defaultTab = 'curl',
|
||||||
|
}: CodeExamplesWidgetProps) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<CodeTab>(defaultTab);
|
||||||
|
|
||||||
|
const getCodeForTab = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'curl':
|
||||||
|
return codeExamples.curl;
|
||||||
|
case 'fetch':
|
||||||
|
return codeExamples.fetch;
|
||||||
|
case 'rest':
|
||||||
|
return codeExamples.rest;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-950/50 rounded-xl border border-slate-700 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 bg-slate-900/50 px-4 py-2 border-b border-slate-700">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-500/50"></div>
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-500/50"></div>
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500/50"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex gap-2 ml-4">
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'curl'}
|
||||||
|
onClick={() => setActiveTab('curl')}
|
||||||
|
label="cURL"
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'fetch'}
|
||||||
|
onClick={() => setActiveTab('fetch')}
|
||||||
|
label="JS Fetch"
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'rest'}
|
||||||
|
onClick={() => setActiveTab('rest')}
|
||||||
|
label="REST"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onCopy(getCodeForTab())}
|
||||||
|
className="px-3 py-1 text-xs bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 rounded transition-colors"
|
||||||
|
aria-label="Copy code"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="p-4 text-xs md:text-sm text-gray-300 overflow-x-auto">
|
||||||
|
<code>{getCodeForTab()}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabButton = ({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-3 py-1 text-xs rounded transition-colors ${
|
||||||
|
active ? 'bg-slate-700 text-white' : 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
'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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||||
import { InspectMode } from './InspectMode';
|
import { InspectMode } from './InspectMode';
|
||||||
import { PromptReuseButton } from './PromptReuseButton';
|
import { PromptReuseButton } from './PromptReuseButton';
|
||||||
import { CompletedTimerBadge } from './GenerationTimer';
|
import { CompletedTimerBadge } from './GenerationTimer';
|
||||||
|
import { CodeExamplesWidget } from './CodeExamplesWidget';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
|
@ -57,7 +58,6 @@ interface ResultCardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'preview' | 'inspect';
|
type ViewMode = 'preview' | 'inspect';
|
||||||
type CodeTab = 'curl' | 'fetch' | 'rest';
|
|
||||||
|
|
||||||
export function ResultCard({
|
export function ResultCard({
|
||||||
result,
|
result,
|
||||||
|
|
@ -68,7 +68,6 @@ export function ResultCard({
|
||||||
onReusePrompt,
|
onReusePrompt,
|
||||||
}: ResultCardProps) {
|
}: ResultCardProps) {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('preview');
|
const [viewMode, setViewMode] = useState<ViewMode>('preview');
|
||||||
const [activeTab, setActiveTab] = useState<CodeTab>('curl');
|
|
||||||
|
|
||||||
// Build enhancement options JSON for code examples
|
// Build enhancement options JSON for code examples
|
||||||
const buildEnhancementOptionsJson = () => {
|
const buildEnhancementOptionsJson = () => {
|
||||||
|
|
@ -165,15 +164,10 @@ X-API-Key: ${apiKey}
|
||||||
"filename": "generated_image"
|
"filename": "generated_image"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const getCodeForTab = () => {
|
const codeExamples = {
|
||||||
switch (activeTab) {
|
curl: curlCode,
|
||||||
case 'curl':
|
fetch: fetchCode,
|
||||||
return curlCode;
|
rest: restCode,
|
||||||
case 'fetch':
|
|
||||||
return fetchCode;
|
|
||||||
case 'rest':
|
|
||||||
return restCode;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -240,12 +234,7 @@ X-API-Key: ${apiKey}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Code Examples */}
|
{/* API Code Examples */}
|
||||||
<CodeExamples
|
<CodeExamplesWidget codeExamples={codeExamples} onCopy={onCopy} />
|
||||||
activeTab={activeTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
code={getCodeForTab()}
|
|
||||||
onCopy={onCopy}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<InspectMode
|
<InspectMode
|
||||||
|
|
@ -385,76 +374,3 @@ function ImagePreview({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Code Examples Component
|
|
||||||
function CodeExamples({
|
|
||||||
activeTab,
|
|
||||||
setActiveTab,
|
|
||||||
code,
|
|
||||||
onCopy,
|
|
||||||
}: {
|
|
||||||
activeTab: CodeTab;
|
|
||||||
setActiveTab: (tab: CodeTab) => void;
|
|
||||||
code: string;
|
|
||||||
onCopy: (text: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-950/50 rounded-xl border border-slate-700 overflow-hidden">
|
|
||||||
<div className="flex items-center gap-2 bg-slate-900/50 px-4 py-2 border-b border-slate-700">
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500/50"></div>
|
|
||||||
<div className="w-3 h-3 rounded-full bg-yellow-500/50"></div>
|
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500/50"></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex gap-2 ml-4">
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'curl'}
|
|
||||||
onClick={() => setActiveTab('curl')}
|
|
||||||
label="cURL"
|
|
||||||
/>
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'fetch'}
|
|
||||||
onClick={() => setActiveTab('fetch')}
|
|
||||||
label="JS Fetch"
|
|
||||||
/>
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'rest'}
|
|
||||||
onClick={() => setActiveTab('rest')}
|
|
||||||
label="REST"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onCopy(code)}
|
|
||||||
className="px-3 py-1 text-xs bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 rounded transition-colors"
|
|
||||||
aria-label="Copy code"
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre className="p-4 text-xs md:text-sm text-gray-300 overflow-x-auto">
|
|
||||||
<code>{code}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
label: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`px-3 py-1 text-xs rounded transition-colors ${
|
|
||||||
active ? 'bg-slate-700 text-white' : 'text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
aria-pressed={active}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CodeExamplesWidget } from './CodeExamplesWidget';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SelectedFileCodePreview Component
|
||||||
|
*
|
||||||
|
* Shows code snippets for uploading a selected file BEFORE upload.
|
||||||
|
* Helps users understand they can either:
|
||||||
|
* 1. Upload via UI button
|
||||||
|
* 2. Copy code and run programmatically
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* - Mobile: Stacked vertically
|
||||||
|
* - Desktop: 3-column grid (1 col preview + 2 cols code)
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SelectedFileCodePreviewProps {
|
||||||
|
file: File;
|
||||||
|
apiKey: string;
|
||||||
|
apiBaseUrl: string;
|
||||||
|
onCopy: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectedFileCodePreview = ({
|
||||||
|
file,
|
||||||
|
apiKey,
|
||||||
|
apiBaseUrl,
|
||||||
|
onCopy,
|
||||||
|
}: SelectedFileCodePreviewProps) => {
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||||
|
|
||||||
|
// Create and cleanup preview URL
|
||||||
|
useEffect(() => {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
setPreviewUrl(objectUrl);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
const codeExamples = generateSelectedFileCodeExamples(file, apiKey, apiBaseUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4" aria-label="Selected File Code Preview">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl md:text-2xl font-bold text-white">
|
||||||
|
Selected File - Code Preview
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
{/* Column 1: Compact File Preview Card */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl h-full">
|
||||||
|
{/* Image Preview - Compact */}
|
||||||
|
<div className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden max-h-32">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={`Preview of ${file.name}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Metadata */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p
|
||||||
|
className="text-white text-sm font-medium truncate"
|
||||||
|
title={file.name}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
{/* File Size */}
|
||||||
|
<span className="px-2 py-1 bg-slate-700/50 text-gray-300 rounded">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* File Type Badge */}
|
||||||
|
<span className="px-2 py-1 bg-purple-600/20 text-purple-400 rounded border border-purple-600/30">
|
||||||
|
{getFileType(file.name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-amber-600/20 text-amber-400 rounded border border-amber-600/30 text-xs font-medium">
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Ready to Upload
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Helper Text */}
|
||||||
|
<p className="text-xs text-gray-500 pt-2">
|
||||||
|
Click upload button or copy code to run programmatically
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columns 2-3: Code Examples Widget */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<CodeExamplesWidget
|
||||||
|
codeExamples={codeExamples}
|
||||||
|
onCopy={onCopy}
|
||||||
|
defaultTab="curl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate production-ready code examples for selected file upload
|
||||||
|
*
|
||||||
|
* Pattern: Users navigate to their folder, then run the upload command.
|
||||||
|
* This matches how real workflows work - users know where their file is.
|
||||||
|
*/
|
||||||
|
const generateSelectedFileCodeExamples = (
|
||||||
|
file: File,
|
||||||
|
apiKey: string,
|
||||||
|
apiBaseUrl: string
|
||||||
|
) => {
|
||||||
|
const fileName = file.name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
curl: `# Navigate to your images folder
|
||||||
|
cd /your/images/folder
|
||||||
|
|
||||||
|
# Upload the file
|
||||||
|
curl -X POST "${apiBaseUrl}/api/upload" \\
|
||||||
|
-H "X-API-Key: ${apiKey}" \\
|
||||||
|
-F "file=@${fileName}"`,
|
||||||
|
|
||||||
|
fetch: `// Set your images folder path
|
||||||
|
const imagePath = '/your/images/folder';
|
||||||
|
const fileName = '${fileName}';
|
||||||
|
|
||||||
|
// For Node.js with fs module:
|
||||||
|
const fs = require('fs');
|
||||||
|
const FormData = require('form-data');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fs.createReadStream(\`\${imagePath}/\${fileName}\`));
|
||||||
|
|
||||||
|
fetch('${apiBaseUrl}/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': '${apiKey}'
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => console.log(data))
|
||||||
|
.catch(error => console.error('Error:', error));`,
|
||||||
|
|
||||||
|
rest: `# From your images folder: /your/images/folder
|
||||||
|
|
||||||
|
POST ${apiBaseUrl}/api/upload
|
||||||
|
Headers:
|
||||||
|
X-API-Key: ${apiKey}
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
Body (form-data):
|
||||||
|
file: @${fileName}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size in human-readable format
|
||||||
|
*/
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file type from filename
|
||||||
|
*/
|
||||||
|
const getFileType = (filename: string): string => {
|
||||||
|
const extension = filename.split('.').pop()?.toUpperCase();
|
||||||
|
if (extension === 'JPG' || extension === 'JPEG') return 'JPEG';
|
||||||
|
if (extension === 'PNG') return 'PNG';
|
||||||
|
if (extension === 'WEBP') return 'WebP';
|
||||||
|
if (extension === 'GIF') return 'GIF';
|
||||||
|
return extension || 'FILE';
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export const EmptyGalleryState = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-6">
|
||||||
|
<div className="w-24 h-24 mb-6 rounded-full bg-slate-800 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl md:text-2xl font-bold text-white mb-3 text-center">
|
||||||
|
No Generated Images Yet
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-gray-400 text-center mb-8 max-w-md">
|
||||||
|
Your generated images will appear here. Start creating AI-powered images using the text-to-image tool.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/demo/tti"
|
||||||
|
className="inline-block px-8 py-4 rounded-xl bg-gradient-to-r from-amber-600 to-orange-600 text-white font-semibold hover:from-amber-500 hover:to-orange-500 transition-all shadow-lg shadow-amber-900/30 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-slate-950 min-h-[44px]"
|
||||||
|
>
|
||||||
|
Generate Images
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
|
||||||
|
import { useImageDownloadTime } from '@/components/shared/ImageCard/useImageDownloadTime';
|
||||||
|
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
||||||
|
import { calculateAspectRatio } from '@/utils/imageUtils';
|
||||||
|
|
||||||
|
type GalleryImageCardProps = {
|
||||||
|
imageUrl: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
lastModified: string;
|
||||||
|
onZoom: (url: string) => void;
|
||||||
|
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GalleryImageCard = ({
|
||||||
|
imageUrl,
|
||||||
|
filename,
|
||||||
|
size,
|
||||||
|
contentType,
|
||||||
|
lastModified,
|
||||||
|
onZoom,
|
||||||
|
onDownloadMeasured,
|
||||||
|
}: GalleryImageCardProps) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [imageDimensions, setImageDimensions] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const { ref } = useIntersectionObserver({
|
||||||
|
onIntersect: () => setIsVisible(true),
|
||||||
|
threshold: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { downloadTime } = useImageDownloadTime(isVisible ? imageUrl : null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (downloadTime !== null) {
|
||||||
|
onDownloadMeasured(imageUrl, downloadTime);
|
||||||
|
}
|
||||||
|
}, [downloadTime, imageUrl, onDownloadMeasured]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
setImageDimensions({
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
img.src = imageUrl;
|
||||||
|
}, [isVisible, imageUrl]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onZoom(imageUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl hover:border-slate-600 transition-all h-full"
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`View full size image: ${filename}`}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{isVisible ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={filename}
|
||||||
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-transparent to-black/70 opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity">
|
||||||
|
<div className="absolute top-3 left-3">
|
||||||
|
<span className="px-3 py-2 bg-black/50 backdrop-blur-sm text-white text-xs sm:text-sm font-medium rounded border border-white/10" aria-label={`Created ${formatTimestamp(lastModified)}`}>
|
||||||
|
{formatTimestamp(lastModified)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute top-3 right-3 max-w-[60%]">
|
||||||
|
<span
|
||||||
|
className="px-3 py-2 bg-black/50 backdrop-blur-sm text-white text-xs sm:text-sm font-medium rounded border border-white/10 truncate block"
|
||||||
|
title={filename}
|
||||||
|
aria-label={`Filename: ${filename}`}
|
||||||
|
>
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="bg-black/60 backdrop-blur-sm rounded-full p-4">
|
||||||
|
<svg
|
||||||
|
className="w-10 h-10 sm:w-12 sm:h-12 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-slate-800 animate-pulse" aria-label="Loading image">
|
||||||
|
<div className="text-gray-600">
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageDimensions && (
|
||||||
|
<ImageMetadataBar
|
||||||
|
width={imageDimensions.width}
|
||||||
|
height={imageDimensions.height}
|
||||||
|
fileSize={size}
|
||||||
|
fileType={contentType}
|
||||||
|
showVisualIndicator={true}
|
||||||
|
downloadMs={downloadTime}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { GalleryImageCard } from './GalleryImageCard';
|
||||||
|
|
||||||
|
type ImageItem = {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
lastModified: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImageGridProps = {
|
||||||
|
images: ImageItem[];
|
||||||
|
onImageZoom: (url: string) => void;
|
||||||
|
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImageGrid = ({ images, onImageZoom, 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) => (
|
||||||
|
<GalleryImageCard
|
||||||
|
key={image.url}
|
||||||
|
imageUrl={image.url}
|
||||||
|
filename={image.name}
|
||||||
|
size={image.size}
|
||||||
|
contentType={image.contentType}
|
||||||
|
lastModified={image.lastModified}
|
||||||
|
onZoom={onImageZoom}
|
||||||
|
onDownloadMeasured={onDownloadMeasured}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { ImageMetadataBar } from '../ImageMetadataBar';
|
||||||
|
import { useImageDownloadTime } from './useImageDownloadTime';
|
||||||
|
|
||||||
|
export interface ImageCardProps {
|
||||||
|
imageUrl: string;
|
||||||
|
filename: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
fileSize: number;
|
||||||
|
fileType: string;
|
||||||
|
onZoom: (url: string) => void;
|
||||||
|
timestamp?: Date;
|
||||||
|
className?: string;
|
||||||
|
measureDownloadTime?: boolean;
|
||||||
|
onDownloadMeasured?: (downloadMs: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageCard = ({
|
||||||
|
imageUrl,
|
||||||
|
filename,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fileSize,
|
||||||
|
fileType,
|
||||||
|
onZoom,
|
||||||
|
timestamp,
|
||||||
|
className = '',
|
||||||
|
measureDownloadTime = false,
|
||||||
|
onDownloadMeasured,
|
||||||
|
}: ImageCardProps) => {
|
||||||
|
const { downloadTime, isLoading } = useImageDownloadTime(
|
||||||
|
measureDownloadTime ? imageUrl : null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (downloadTime !== null && onDownloadMeasured) {
|
||||||
|
onDownloadMeasured(downloadTime);
|
||||||
|
}
|
||||||
|
}, [downloadTime, onDownloadMeasured]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onZoom(imageUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (date: Date): string => {
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl hover:border-slate-600 transition-all h-full ${className}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative"
|
||||||
|
onClick={() => onZoom(imageUrl)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="View full size image"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={filename}
|
||||||
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-transparent to-black/70 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{timestamp && (
|
||||||
|
<div className="absolute top-3 left-3">
|
||||||
|
<span className="px-2.5 py-1 bg-black/50 backdrop-blur-sm text-white text-xs font-medium rounded border border-white/10">
|
||||||
|
{formatTimestamp(timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute top-3 right-3 max-w-[60%]">
|
||||||
|
<span
|
||||||
|
className="px-2.5 py-1 bg-black/50 backdrop-blur-sm text-white text-xs font-medium rounded border border-white/10 truncate block"
|
||||||
|
title={filename}
|
||||||
|
>
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{width && height && (
|
||||||
|
<ImageMetadataBar
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fileSize={fileSize}
|
||||||
|
fileType={fileType}
|
||||||
|
showVisualIndicator={true}
|
||||||
|
downloadMs={downloadTime}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
type UseImageDownloadTimeReturn = {
|
||||||
|
downloadTime: number | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useImageDownloadTime = (imageUrl: string | null): UseImageDownloadTimeReturn => {
|
||||||
|
const [downloadTime, setDownloadTime] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageUrl) {
|
||||||
|
setDownloadTime(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureDownloadTime = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = performance.now();
|
||||||
|
const cacheBustUrl = `${imageUrl}?_t=${Date.now()}`;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const endTime = performance.now();
|
||||||
|
const duration = Math.round(endTime - startTime);
|
||||||
|
setDownloadTime(duration);
|
||||||
|
setIsLoading(false);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
setDownloadTime(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = cacheBustUrl;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setDownloadTime(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
measureDownloadTime();
|
||||||
|
}, [imageUrl]);
|
||||||
|
|
||||||
|
return { downloadTime, isLoading };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { calculateAspectRatio, formatFileSize, getFileTypeFromMimeType, formatDuration } from '@/utils/imageUtils';
|
||||||
|
import { getDownloadPerformance } from '@/utils/performanceColors';
|
||||||
|
|
||||||
|
interface ImageMetadataBarProps {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fileSize: number;
|
||||||
|
fileType: string;
|
||||||
|
className?: string;
|
||||||
|
showVisualIndicator?: boolean;
|
||||||
|
downloadMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AspectRatioIcon = ({ width, height }: { width: number; height: number }) => {
|
||||||
|
const ratio = width / height;
|
||||||
|
const maxSize = 16;
|
||||||
|
|
||||||
|
let rectWidth: number;
|
||||||
|
let rectHeight: number;
|
||||||
|
|
||||||
|
if (ratio > 1) {
|
||||||
|
rectWidth = maxSize;
|
||||||
|
rectHeight = maxSize / ratio;
|
||||||
|
} else {
|
||||||
|
rectHeight = maxSize;
|
||||||
|
rectWidth = maxSize * ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = (20 - rectWidth) / 2;
|
||||||
|
const y = (20 - rectHeight) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={rectWidth}
|
||||||
|
height={rectHeight}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
className="text-slate-400"
|
||||||
|
rx="1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DownloadIcon = () => (
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 1 L6 8 M6 8 L3 5 M6 8 L9 5" />
|
||||||
|
<path d="M1 11 L11 11" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ImageMetadataBar = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fileSize,
|
||||||
|
fileType,
|
||||||
|
className = '',
|
||||||
|
showVisualIndicator = true,
|
||||||
|
downloadMs,
|
||||||
|
}: ImageMetadataBarProps) => {
|
||||||
|
const aspectRatio = calculateAspectRatio(width, height);
|
||||||
|
const formattedSize = formatFileSize(fileSize);
|
||||||
|
const formattedType = getFileTypeFromMimeType(fileType);
|
||||||
|
|
||||||
|
const dimensionsTooltip = `Image dimensions: ${width} pixels wide by ${height} pixels tall`;
|
||||||
|
const aspectRatioTooltip = `Aspect ratio: ${aspectRatio} (width to height proportion)`;
|
||||||
|
const fileSizeTooltip = `File size: ${formattedSize} (${fileSize.toLocaleString()} bytes)`;
|
||||||
|
const fileTypeTooltip = `File format: ${formattedType} image`;
|
||||||
|
const visualIndicatorTooltip = 'Aspect ratio visual indicator';
|
||||||
|
|
||||||
|
let downloadTooltip = '';
|
||||||
|
let downloadTimeText = '';
|
||||||
|
let downloadPerformance = null;
|
||||||
|
|
||||||
|
if (downloadMs !== null && downloadMs !== undefined) {
|
||||||
|
downloadPerformance = getDownloadPerformance(downloadMs);
|
||||||
|
downloadTimeText = formatDuration(downloadMs);
|
||||||
|
downloadTooltip = `Download time: ${downloadTimeText} (${downloadPerformance.label}). ${downloadPerformance.description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ariaLabel = `Image details: ${width} by ${height} pixels, ${aspectRatio} aspect ratio, ${formattedSize}, ${formattedType} format${downloadMs ? `, downloaded in ${downloadTimeText}` : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between px-2.5 py-2 sm:px-3 bg-slate-800/50 border border-slate-700 rounded-lg text-xs ${className}`}
|
||||||
|
role="status"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap min-w-0">
|
||||||
|
<span className="font-medium text-white cursor-help whitespace-nowrap text-[11px] sm:text-xs" title={dimensionsTooltip}>
|
||||||
|
{width} × {height}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-gray-600 hidden xs:inline" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex items-center gap-1 text-gray-400 cursor-help whitespace-nowrap text-[11px] sm:text-xs" title={aspectRatioTooltip}>
|
||||||
|
<span>{aspectRatio}</span>
|
||||||
|
{showVisualIndicator && (
|
||||||
|
<span className="hidden sm:inline" title={visualIndicatorTooltip}>
|
||||||
|
<AspectRatioIcon width={width} height={height} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-gray-600 hidden xs:inline" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-gray-400 cursor-help whitespace-nowrap text-[11px] sm:text-xs" title={fileSizeTooltip}>
|
||||||
|
{formattedSize}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-gray-600 hidden sm:inline" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-gray-400 cursor-help whitespace-nowrap text-[11px] sm:text-xs hidden sm:inline" title={fileTypeTooltip}>
|
||||||
|
{formattedType}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{downloadMs !== null && downloadMs !== undefined && downloadPerformance && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-600 hidden sm:inline" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex items-center gap-1.5 cursor-help whitespace-nowrap text-[11px] sm:text-xs hidden sm:flex" title={downloadTooltip}>
|
||||||
|
<DownloadIcon />
|
||||||
|
<span className={downloadPerformance.color}>{downloadTimeText}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
type UseIntersectionObserverOptions = {
|
||||||
|
threshold?: number;
|
||||||
|
rootMargin?: string;
|
||||||
|
onIntersect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseIntersectionObserverReturn = {
|
||||||
|
ref: React.RefObject<HTMLDivElement | null>;
|
||||||
|
isIntersecting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIntersectionObserver = ({
|
||||||
|
onIntersect,
|
||||||
|
threshold = 0.1,
|
||||||
|
rootMargin = '0px',
|
||||||
|
}: UseIntersectionObserverOptions): UseIntersectionObserverReturn => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [isIntersecting, setIsIntersecting] = useState(false);
|
||||||
|
const hasIntersectedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const intersecting = entry.isIntersecting;
|
||||||
|
setIsIntersecting(intersecting);
|
||||||
|
|
||||||
|
if (intersecting && !hasIntersectedRef.current) {
|
||||||
|
hasIntersectedRef.current = true;
|
||||||
|
onIntersect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold,
|
||||||
|
rootMargin,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [onIntersect, threshold, rootMargin]);
|
||||||
|
|
||||||
|
return { ref, isIntersecting };
|
||||||
|
};
|
||||||
|
|
@ -4,7 +4,7 @@ import * as schema from '@banatie/database';
|
||||||
|
|
||||||
const connectionString =
|
const connectionString =
|
||||||
process.env.DATABASE_URL ||
|
process.env.DATABASE_URL ||
|
||||||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
|
'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db';
|
||||||
|
|
||||||
// Create postgres client
|
// Create postgres client
|
||||||
const client = postgres(connectionString);
|
const client = postgres(connectionString);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* Image utility functions for metadata processing
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface StandardRatio {
|
||||||
|
ratio: string;
|
||||||
|
decimal: number;
|
||||||
|
tolerance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STANDARD_RATIOS: StandardRatio[] = [
|
||||||
|
{ ratio: '1:1', decimal: 1.0, tolerance: 0.02 }, // Square
|
||||||
|
{ ratio: '4:3', decimal: 1.333, tolerance: 0.02 }, // Standard
|
||||||
|
{ ratio: '3:2', decimal: 1.5, tolerance: 0.02 }, // Classic photo
|
||||||
|
{ ratio: '16:10', decimal: 1.6, tolerance: 0.02 }, // Common monitor
|
||||||
|
{ ratio: '16:9', decimal: 1.778, tolerance: 0.02 }, // Widescreen
|
||||||
|
{ ratio: '21:9', decimal: 2.333, tolerance: 0.02 }, // Ultrawide
|
||||||
|
{ ratio: '9:16', decimal: 0.5625, tolerance: 0.02 }, // Vertical video
|
||||||
|
{ ratio: '2:3', decimal: 0.667, tolerance: 0.02 }, // Portrait photo
|
||||||
|
{ ratio: '3:4', decimal: 0.75, tolerance: 0.02 }, // Portrait standard
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate aspect ratio with tolerance for standard ratios
|
||||||
|
* @param width - Image width in pixels
|
||||||
|
* @param height - Image height in pixels
|
||||||
|
* @returns Aspect ratio as string (e.g., "16:9" or "1.78")
|
||||||
|
*/
|
||||||
|
export const calculateAspectRatio = (width: number, height: number): string => {
|
||||||
|
const decimal = width / height;
|
||||||
|
|
||||||
|
// Check if it matches a standard ratio within tolerance
|
||||||
|
for (const standard of STANDARD_RATIOS) {
|
||||||
|
const diff = Math.abs(decimal - standard.decimal) / standard.decimal;
|
||||||
|
if (diff <= standard.tolerance) {
|
||||||
|
return standard.ratio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no match, return decimal with 2 places
|
||||||
|
return decimal.toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size in human-readable format
|
||||||
|
* @param bytes - File size in bytes
|
||||||
|
* @returns Formatted string (e.g., "2.4 MB")
|
||||||
|
*/
|
||||||
|
export const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file type from MIME type
|
||||||
|
* @param contentType - MIME type (e.g., "image/png")
|
||||||
|
* @returns File type (e.g., "PNG")
|
||||||
|
*/
|
||||||
|
export const getFileTypeFromMimeType = (contentType: string): string => {
|
||||||
|
const type = contentType.split('/')[1];
|
||||||
|
return type ? type.toUpperCase() : 'UNKNOWN';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in milliseconds to human-readable format
|
||||||
|
* @param ms - Duration in milliseconds
|
||||||
|
* @returns Formatted string (e.g., "143ms" or "1.23s")
|
||||||
|
*/
|
||||||
|
export const formatDuration = (ms: number): string => {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(2)}s`;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
export const DOWNLOAD_THRESHOLDS = {
|
||||||
|
FAST: 200,
|
||||||
|
MODERATE: 600,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type DownloadPerformance = {
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDownloadPerformance = (downloadMs: number): DownloadPerformance => {
|
||||||
|
if (downloadMs < DOWNLOAD_THRESHOLDS.FAST) {
|
||||||
|
return {
|
||||||
|
color: 'text-green-400',
|
||||||
|
label: 'Excellent',
|
||||||
|
description: 'CDN cache hit with optimal image size',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadMs < DOWNLOAD_THRESHOLDS.MODERATE) {
|
||||||
|
return {
|
||||||
|
color: 'text-yellow-400',
|
||||||
|
label: 'Good',
|
||||||
|
description: 'Cache hit but large file, or slower CDN response',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: 'text-red-400',
|
||||||
|
label: 'Poor',
|
||||||
|
description: 'CDN miss, large file size, or optimization needed',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
# Environment Configuration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Banatie uses a monorepo structure with **two separate environment configurations**:
|
||||||
|
- **Development** (`apps/api-service/`) - Infrastructure in Docker, API runs locally
|
||||||
|
- **Production** (`prod-env/`) - All services run in Docker containers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Services & Ports
|
||||||
|
|
||||||
|
| Service | Port(s) | Description | Container Name |
|
||||||
|
|-----------------|--------------|------------------------------|----------------------------|
|
||||||
|
| PostgreSQL | 5460 → 5432 | Database | banatie-postgres(-dev) |
|
||||||
|
| MinIO API | 9000 → 9000 | Object storage (S3) | banatie-storage(-dev) |
|
||||||
|
| MinIO Console | 9001 → 9001 | Storage management UI | banatie-storage(-dev) |
|
||||||
|
| API Service | 3000 → 3000 | REST API (prod only) | banatie-app |
|
||||||
|
| Landing Page | 3001 → 3000 | Public website (prod only) | banatie-landing |
|
||||||
|
| Studio Platform | 3002 | SaaS (future) | - |
|
||||||
|
| Admin Dashboard | 3003 | Administration (future) | - |
|
||||||
|
|
||||||
|
**Port Format**: `host:container` (e.g., `5460:5432` means host port 5460 maps to container port 5432)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime Modes
|
||||||
|
|
||||||
|
### Development Mode (`apps/api-service/`)
|
||||||
|
|
||||||
|
**Use Case**: Active development with hot reload
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```
|
||||||
|
apps/api-service/
|
||||||
|
├── docker-compose.yml # Infrastructure only (postgres, minio)
|
||||||
|
├── .env # Dev config (localhost endpoints)
|
||||||
|
└── secrets.env # API keys (not in git)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Launch**:
|
||||||
|
```bash
|
||||||
|
cd apps/api-service
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**What happens**:
|
||||||
|
1. Starts infrastructure: postgres, minio (Docker)
|
||||||
|
2. API runs locally with `tsx --watch` (hot reload)
|
||||||
|
3. Connects via port forwarding: `localhost:5460`, `localhost:9000`
|
||||||
|
|
||||||
|
**Configuration**:
|
||||||
|
- Database: `localhost:5460`
|
||||||
|
- MinIO: `localhost:9000`
|
||||||
|
- Hot reload enabled
|
||||||
|
- Uses local `.env` + `secrets.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Production Mode (`prod-env/`)
|
||||||
|
|
||||||
|
**Use Case**: Production deployment, local testing
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```
|
||||||
|
prod-env/
|
||||||
|
├── docker-compose.yml # All services (postgres, minio, api, landing)
|
||||||
|
├── .env # Prod config (Docker hostnames)
|
||||||
|
├── secrets.env # API keys (not in git)
|
||||||
|
└── README.md # Deployment instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Launch**:
|
||||||
|
```bash
|
||||||
|
cd prod-env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**What happens**:
|
||||||
|
1. All services run in Docker containers
|
||||||
|
2. Internal Docker network: `postgres:5432`, `minio:9000`
|
||||||
|
3. Host access via port forwarding
|
||||||
|
4. Production-optimized builds
|
||||||
|
|
||||||
|
**Configuration**:
|
||||||
|
- Database: `postgres:5432` (internal)
|
||||||
|
- MinIO: `minio:9000` (internal)
|
||||||
|
- Production build (TypeScript compiled)
|
||||||
|
- Uses prod `.env` + `secrets.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Files
|
||||||
|
|
||||||
|
### Development `.env` (`apps/api-service/.env`)
|
||||||
|
|
||||||
|
**Purpose**: Local development configuration
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Connects to Docker via port forwarding
|
||||||
|
DATABASE_URL=postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db
|
||||||
|
MINIO_ENDPOINT=localhost:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Committed to git** (no secrets)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Production `.env` (`prod-env/.env`)
|
||||||
|
|
||||||
|
**Purpose**: Production Docker configuration
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Internal Docker network hostnames
|
||||||
|
DATABASE_URL=postgresql://banatie_user:banatie_secure_password@postgres:5432/banatie_db
|
||||||
|
MINIO_ENDPOINT=minio:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Committed to git** (no secrets)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Secrets (`secrets.env`)
|
||||||
|
|
||||||
|
**Purpose**: Sensitive API keys and credentials
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Required
|
||||||
|
GEMINI_API_KEY=your_key_here
|
||||||
|
|
||||||
|
# Optional (generated by API)
|
||||||
|
MASTER_KEY=will_be_generated
|
||||||
|
API_KEY=will_be_generated
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ **NOT committed to git**
|
||||||
|
✅ Template available: `secrets.env.example`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Configuration Differences
|
||||||
|
|
||||||
|
| Variable | Dev (`apps/api-service/.env`) | Prod (`prod-env/.env`) |
|
||||||
|
|----------|-------------------------------|------------------------|
|
||||||
|
| `DATABASE_URL` | `localhost:5460` | `postgres:5432` |
|
||||||
|
| `MINIO_ENDPOINT` | `localhost:9000` | `minio:9000` |
|
||||||
|
| `RESULTS_DIR` | `./results` | `/app/results` |
|
||||||
|
| `UPLOADS_DIR` | `./uploads/temp` | `/app/uploads/temp` |
|
||||||
|
| `IS_DOCKER` | not set | `true` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How db.ts Loads Config
|
||||||
|
|
||||||
|
The `apps/api-service/src/db.ts` file has smart loading:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Load .env only if NOT in Docker
|
||||||
|
if (existsSync(envPath) && !process.env['IS_DOCKER']) {
|
||||||
|
config({ path: envPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Always load secrets.env if exists
|
||||||
|
if (existsSync(secretsPath)) {
|
||||||
|
config({ path: secretsPath });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Dev mode**: Loads `apps/api-service/.env` + `secrets.env`
|
||||||
|
- **Prod mode**: Uses Docker env vars + `secrets.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Network
|
||||||
|
|
||||||
|
- **Network Name**: `banatie-network` (prod), `banatie-dev-network` (dev)
|
||||||
|
- **Internal DNS**: Containers reach each other by service name
|
||||||
|
- **Host Access**: Use `localhost` with mapped ports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Credentials
|
||||||
|
|
||||||
|
**PostgreSQL**:
|
||||||
|
- Host (dev): `localhost:5460`
|
||||||
|
- Host (prod): `postgres:5432`
|
||||||
|
- Database: `banatie_db`
|
||||||
|
- User: `banatie_user`
|
||||||
|
- Password: `banatie_secure_password`
|
||||||
|
|
||||||
|
**MinIO**:
|
||||||
|
- Endpoint (dev): `http://localhost:9000`
|
||||||
|
- Endpoint (prod): `http://minio:9000`
|
||||||
|
- Console: `http://localhost:9001`
|
||||||
|
- Root User: `banatie_admin`
|
||||||
|
- Root Password: `banatie_storage_secure_key_2024`
|
||||||
|
- Service Account: `banatie_service` / `banatie_service_key_2024`
|
||||||
|
- Bucket: `banatie`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage Layout
|
||||||
|
|
||||||
|
**MinIO Structure**:
|
||||||
|
```
|
||||||
|
banatie/
|
||||||
|
├── {orgSlug}/
|
||||||
|
│ └── {projectSlug}/
|
||||||
|
│ ├── generated/
|
||||||
|
│ │ └── {year-month}/
|
||||||
|
│ │ └── {filename}.{ext}
|
||||||
|
│ └── uploads/
|
||||||
|
│ └── {year-month}/
|
||||||
|
│ └── {filename}.{ext}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local Volumes** (both modes):
|
||||||
|
- Database: `data/postgres/`
|
||||||
|
- MinIO: `data/storage/drive{1-4}/` (SNMD mode)
|
||||||
|
- Results: `data/results/`
|
||||||
|
- Uploads: `data/uploads/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev environment
|
||||||
|
cd apps/api-service
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Stop infrastructure
|
||||||
|
pnpm infra:down
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
pnpm infra:logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
cd prod-env
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f app # API
|
||||||
|
docker compose logs -f landing # Landing
|
||||||
|
docker compose logs -f postgres # DB
|
||||||
|
|
||||||
|
# Stop all
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Rebuild after code changes
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From host (both modes)
|
||||||
|
psql -h localhost -p 5460 -U banatie_user -d banatie_db
|
||||||
|
|
||||||
|
# From Docker container (prod only)
|
||||||
|
docker exec -it banatie-postgres psql -U banatie_user -d banatie_db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health Checks
|
||||||
|
|
||||||
|
All services have health checks:
|
||||||
|
|
||||||
|
- **PostgreSQL**: `pg_isready -U banatie_user -d banatie_db`
|
||||||
|
- **MinIO**: `curl -f http://localhost:9000/minio/health/live`
|
||||||
|
|
||||||
|
Intervals: 30s, timeout: 10s, retries: 3, start period: 40s
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
Check what's using the port:
|
||||||
|
```bash
|
||||||
|
lsof -i :5460 # PostgreSQL
|
||||||
|
lsof -i :9000 # MinIO
|
||||||
|
lsof -i :3000 # API
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Refused
|
||||||
|
|
||||||
|
1. Check containers are running:
|
||||||
|
```bash
|
||||||
|
docker ps | grep banatie
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check health status:
|
||||||
|
```bash
|
||||||
|
docker compose ps # In respective directory
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify port in `.env` matches mode:
|
||||||
|
- Dev: `localhost:5460`
|
||||||
|
- Prod: `postgres:5432`
|
||||||
|
|
||||||
|
### MinIO Connection Refused
|
||||||
|
|
||||||
|
1. Check MinIO is healthy:
|
||||||
|
```bash
|
||||||
|
docker logs banatie-storage(-dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify endpoint in `.env`:
|
||||||
|
- Dev: `localhost:9000`
|
||||||
|
- Prod: `minio:9000`
|
||||||
|
|
||||||
|
3. Check storage-init completed:
|
||||||
|
```bash
|
||||||
|
docker logs banatie-storage-init(-dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
### "No such file or directory" for secrets.env
|
||||||
|
|
||||||
|
Create from template:
|
||||||
|
```bash
|
||||||
|
# Dev
|
||||||
|
cd apps/api-service
|
||||||
|
cp secrets.env.example secrets.env
|
||||||
|
# Edit with your GEMINI_API_KEY
|
||||||
|
|
||||||
|
# Prod
|
||||||
|
cd prod-env
|
||||||
|
cp secrets.env.example secrets.env
|
||||||
|
# Edit with your GEMINI_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### From Old Structure to New
|
||||||
|
|
||||||
|
The project was reorganized to separate dev/prod configs:
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```
|
||||||
|
/.env # Confusing mix of localhost/docker
|
||||||
|
/docker-compose.yml # Unclear if dev or prod
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**:
|
||||||
|
```
|
||||||
|
/apps/api-service/
|
||||||
|
├── docker-compose.yml # Dev infrastructure
|
||||||
|
└── .env # Dev config (localhost)
|
||||||
|
|
||||||
|
/prod-env/
|
||||||
|
├── docker-compose.yml # Prod all services
|
||||||
|
└── .env # Prod config (docker hostnames)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
1. Port: `5434` → `5460` (everywhere)
|
||||||
|
2. Secrets: Moved to `secrets.env` (not in git)
|
||||||
|
3. `db.ts`: Removed `override: true`, added `IS_DOCKER` check
|
||||||
|
4. Dockerfile: `Dockerfile.mono` → `Dockerfile`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Never commit `secrets.env`** - Use templates
|
||||||
|
2. **Use correct config for mode** - Dev vs Prod `.env`
|
||||||
|
3. **Test locally before deploy** - Use `prod-env` locally
|
||||||
|
4. **Monitor health checks** - Ensure services are healthy
|
||||||
|
5. **Backup data directory** - Especially `data/postgres/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- ✅ Document environment configuration
|
||||||
|
- ✅ Separate dev/prod configurations
|
||||||
|
- ✅ Update port from 5434 to 5460
|
||||||
|
- ✅ Move secrets to separate file
|
||||||
|
- 🔜 Test dev mode
|
||||||
|
- 🔜 Test prod mode locally
|
||||||
|
- 🔜 Deploy to VPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-01-12
|
||||||
|
**Author**: Claude Code
|
||||||
|
**Version**: 2.0 (Post-reorganization)
|
||||||
25
package.json
25
package.json
|
|
@ -10,39 +10,16 @@
|
||||||
"pnpm": ">=8.0.0"
|
"pnpm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --parallel run dev",
|
|
||||||
"dev:api": "pnpm --filter @banatie/api-service dev",
|
|
||||||
"dev:landing": "pnpm --filter @banatie/landing dev",
|
|
||||||
"dev:studio": "pnpm --filter @banatie/studio dev",
|
|
||||||
"dev:admin": "pnpm --filter @banatie/admin dev",
|
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"build:api": "pnpm --filter @banatie/api-service build",
|
|
||||||
"build:landing": "pnpm --filter @banatie/landing build",
|
|
||||||
"build:studio": "pnpm --filter @banatie/studio build",
|
|
||||||
"build:admin": "pnpm --filter @banatie/admin build",
|
|
||||||
"start:api": "pnpm --filter @banatie/api-service start",
|
|
||||||
"start:landing": "pnpm --filter @banatie/landing start",
|
|
||||||
"start:studio": "pnpm --filter @banatie/studio start",
|
|
||||||
"start:admin": "pnpm --filter @banatie/admin start",
|
|
||||||
"lint": "pnpm -r lint",
|
"lint": "pnpm -r lint",
|
||||||
"lint:api": "pnpm --filter @banatie/api-service lint",
|
|
||||||
"lint:landing": "pnpm --filter @banatie/landing lint",
|
|
||||||
"lint:studio": "pnpm --filter @banatie/studio lint",
|
|
||||||
"lint:admin": "pnpm --filter @banatie/admin lint",
|
|
||||||
"typecheck": "pnpm -r typecheck",
|
"typecheck": "pnpm -r typecheck",
|
||||||
"typecheck:api": "pnpm --filter @banatie/api-service typecheck",
|
|
||||||
"typecheck:landing": "pnpm --filter @banatie/landing typecheck",
|
|
||||||
"typecheck:studio": "pnpm --filter @banatie/studio typecheck",
|
|
||||||
"typecheck:admin": "pnpm --filter @banatie/admin typecheck",
|
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:api": "pnpm --filter @banatie/api-service test",
|
|
||||||
"format": "prettier --write \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
"format": "prettier --write \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
||||||
"format:check": "prettier --check \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
"format:check": "prettier --check \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
||||||
"clean": "pnpm -r clean && rm -rf node_modules",
|
"clean": "pnpm -r clean && rm -rf node_modules"
|
||||||
"install:all": "pnpm install"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"monorepo",
|
"monorepo",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,6 @@ export default {
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url:
|
url:
|
||||||
process.env.DATABASE_URL ||
|
process.env.DATABASE_URL ||
|
||||||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db',
|
'postgresql://banatie_user:banatie_secure_password@localhost:5460/banatie_db',
|
||||||
},
|
},
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Production Environment Configuration
|
||||||
|
# This file is used when running docker compose in prod-env/
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
API_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
|
# Database Configuration (Docker internal network)
|
||||||
|
DB_HOST=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=banatie_db
|
||||||
|
DB_USER=banatie_user
|
||||||
|
DB_PASSWORD=banatie_secure_password
|
||||||
|
DATABASE_URL=postgresql://banatie_user:banatie_secure_password@postgres:5432/banatie_db
|
||||||
|
|
||||||
|
# MinIO Storage Configuration (Docker internal network)
|
||||||
|
MINIO_ROOT_USER=banatie_admin
|
||||||
|
MINIO_ROOT_PASSWORD=banatie_storage_secure_key_2024
|
||||||
|
STORAGE_TYPE=minio
|
||||||
|
MINIO_ENDPOINT=minio:9000
|
||||||
|
MINIO_ACCESS_KEY=banatie_service
|
||||||
|
MINIO_SECRET_KEY=banatie_service_key_2024
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_BUCKET_NAME=banatie
|
||||||
|
MINIO_PUBLIC_URL=http://localhost:9000
|
||||||
|
|
||||||
|
# Multi-tenancy Configuration
|
||||||
|
DEFAULT_ORG_ID=default
|
||||||
|
DEFAULT_PROJECT_ID=main
|
||||||
|
DEFAULT_USER_ID=system
|
||||||
|
|
||||||
|
# Presigned URL Configuration
|
||||||
|
PRESIGNED_URL_EXPIRY=86400 # 24 hours
|
||||||
|
|
||||||
|
# File Upload Configuration
|
||||||
|
MAX_FILE_SIZE=5242880 # 5MB
|
||||||
|
MAX_FILES=3
|
||||||
|
|
||||||
|
# Directory Configuration (Docker paths)
|
||||||
|
RESULTS_DIR=/app/results
|
||||||
|
UPLOADS_DIR=/app/uploads/temp
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
TTI_LOG=logs/tti-log.md
|
||||||
|
ENH_LOG=logs/enhancing.md
|
||||||
|
|
||||||
|
# IMPORTANT: Sensitive values should be in secrets.env (not tracked in git)
|
||||||
|
# See secrets.env.example for required variables
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
# Production Environment
|
||||||
|
|
||||||
|
This directory contains the production Docker Compose configuration for running all Banatie services in containers.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- **API Service** (port 3000) - REST API for image generation
|
||||||
|
- **Landing Page** (port 3001) - Public website
|
||||||
|
- **PostgreSQL** (port 5460→5432) - Database
|
||||||
|
- **MinIO** (ports 9000-9001) - Object storage with S3 compatibility
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Setup Secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp secrets.env.example secrets.env
|
||||||
|
# Edit secrets.env with real values
|
||||||
|
```
|
||||||
|
|
||||||
|
Required secrets:
|
||||||
|
- `GEMINI_API_KEY` - Your Google Gemini API key
|
||||||
|
|
||||||
|
### 2. Start Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From prod-env directory
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Check Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f app # API logs
|
||||||
|
docker compose logs -f landing # Landing logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Stop Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment to VPS
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On VPS
|
||||||
|
cd /path/to/banatie-service
|
||||||
|
git pull
|
||||||
|
cd prod-env
|
||||||
|
cp secrets.env.example secrets.env
|
||||||
|
# Edit secrets.env with production values
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On VPS
|
||||||
|
cd /path/to/banatie-service/prod-env
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configuration is split into two files:
|
||||||
|
|
||||||
|
- **`.env`** - Base configuration (committed to git)
|
||||||
|
- Service endpoints (Docker internal: `postgres:5432`, `minio:9000`)
|
||||||
|
- Database credentials (development values)
|
||||||
|
- Storage configuration
|
||||||
|
- Application settings
|
||||||
|
|
||||||
|
- **`secrets.env`** - Sensitive secrets (NOT committed)
|
||||||
|
- API keys (Gemini)
|
||||||
|
- Production passwords (if different)
|
||||||
|
- Testing keys (optional)
|
||||||
|
|
||||||
|
## Port Mappings
|
||||||
|
|
||||||
|
| Service | Host Port | Container Port | Description |
|
||||||
|
|------------|-----------|----------------|-----------------------|
|
||||||
|
| API | 3000 | 3000 | REST API |
|
||||||
|
| Landing | 3001 | 3000 | Landing page |
|
||||||
|
| PostgreSQL | 5460 | 5432 | Database |
|
||||||
|
| MinIO API | 9000 | 9000 | S3-compatible storage |
|
||||||
|
| MinIO UI | 9001 | 9001 | Web console |
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
All data is stored in the parent `data/` directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
../data/
|
||||||
|
├── postgres/ # Database files
|
||||||
|
├── storage/ # MinIO storage (4 drives for SNMD)
|
||||||
|
├── results/ # Generated images
|
||||||
|
└── uploads/ # Uploaded files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessing Services
|
||||||
|
|
||||||
|
- **API**: http://localhost:3000
|
||||||
|
- **Landing**: http://localhost:3001
|
||||||
|
- **MinIO Console**: http://localhost:9001
|
||||||
|
- Username: `banatie_admin`
|
||||||
|
- Password: (from MINIO_ROOT_PASSWORD in .env)
|
||||||
|
|
||||||
|
## Database Access
|
||||||
|
|
||||||
|
Connect to PostgreSQL from host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h localhost -p 5460 -U banatie_user -d banatie_db
|
||||||
|
```
|
||||||
|
|
||||||
|
From another Docker container (same network):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h postgres -p 5432 -U banatie_user -d banatie_db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Check service health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f # All services
|
||||||
|
docker compose logs -f app # API only
|
||||||
|
docker compose logs -f postgres # Database only
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart specific service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild after code changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset everything
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v # ⚠️ This deletes volumes!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
1. **Secrets Management**
|
||||||
|
- Never commit `secrets.env`
|
||||||
|
- Use strong passwords in production
|
||||||
|
- Rotate API keys regularly
|
||||||
|
|
||||||
|
2. **Database Backups**
|
||||||
|
- Set up automated backups of `data/postgres/`
|
||||||
|
- Test restore procedures
|
||||||
|
|
||||||
|
3. **Resource Limits**
|
||||||
|
- Add memory/CPU limits to docker-compose.yml if needed
|
||||||
|
- Monitor with `docker stats`
|
||||||
|
|
||||||
|
4. **SSL/TLS**
|
||||||
|
- Use reverse proxy (nginx/traefik) for HTTPS
|
||||||
|
- Enable MinIO SSL for production
|
||||||
|
|
||||||
|
5. **Monitoring**
|
||||||
|
- Set up health check endpoints
|
||||||
|
- Configure alerts for service failures
|
||||||
|
|
||||||
|
## Development vs Production
|
||||||
|
|
||||||
|
This configuration is for **production** (all services in Docker).
|
||||||
|
|
||||||
|
For **development** (local API, Docker infrastructure):
|
||||||
|
- Use `apps/api-service/docker-compose.yml`
|
||||||
|
- Run `pnpm dev` from api-service directory
|
||||||
|
- Connects to Docker services via `localhost:5460` and `localhost:9000`
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# API Service
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ..
|
||||||
dockerfile: ./apps/api-service/Dockerfile.mono
|
dockerfile: apps/api-service/Dockerfile
|
||||||
target: development
|
target: production
|
||||||
container_name: banatie-app
|
container_name: banatie-app
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/api-service/src:/app/apps/api-service/src
|
- ../apps/api-service/logs:/app/apps/api-service/logs
|
||||||
- ./apps/api-service/logs:/app/apps/api-service/logs
|
- ../data/results:/app/results
|
||||||
- ./packages:/app/packages
|
- ../data/uploads:/app/uploads
|
||||||
networks:
|
networks:
|
||||||
- banatie-network
|
- banatie-network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -19,40 +19,43 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
- secrets.env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- IS_DOCKER=true
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- NODE_ENV=production
|
||||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
|
||||||
- STORAGE_TYPE=${STORAGE_TYPE}
|
|
||||||
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
|
||||||
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
|
||||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
|
||||||
- MINIO_USE_SSL=${MINIO_USE_SSL}
|
|
||||||
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME}
|
|
||||||
- MINIO_PUBLIC_URL=${MINIO_PUBLIC_URL}
|
|
||||||
- API_BASE_URL=${API_BASE_URL}
|
|
||||||
- DEFAULT_ORG_ID=${DEFAULT_ORG_ID}
|
|
||||||
- DEFAULT_PROJECT_ID=${DEFAULT_PROJECT_ID}
|
|
||||||
- DEFAULT_USER_ID=${DEFAULT_USER_ID}
|
|
||||||
- PRESIGNED_URL_EXPIRY=${PRESIGNED_URL_EXPIRY}
|
|
||||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE}
|
|
||||||
- MAX_FILES=${MAX_FILES}
|
|
||||||
- RESULTS_DIR=${RESULTS_DIR}
|
|
||||||
- UPLOADS_DIR=${UPLOADS_DIR}
|
|
||||||
- LOG_LEVEL=${LOG_LEVEL}
|
|
||||||
- PORT=${PORT}
|
|
||||||
- CORS_ORIGIN=${CORS_ORIGIN}
|
|
||||||
- TTI_LOG=${TTI_LOG}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Landing Page
|
||||||
|
landing:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/landing/Dockerfile
|
||||||
|
container_name: banatie-landing
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
networks:
|
||||||
|
- banatie-network
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
- secrets.env
|
||||||
|
environment:
|
||||||
|
- IS_DOCKER=true
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# PostgreSQL Database
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: banatie-postgres
|
container_name: banatie-postgres
|
||||||
ports:
|
ports:
|
||||||
- "5434:5432"
|
- "5460:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ../data/postgres:/var/lib/postgresql/data
|
||||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql
|
- ../scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql
|
||||||
networks:
|
networks:
|
||||||
- banatie-network
|
- banatie-network
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -67,19 +70,19 @@ services:
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# SNMD MinIO Setup - Production Ready
|
# MinIO Object Storage - Production Ready with SNMD
|
||||||
minio:
|
minio:
|
||||||
image: quay.io/minio/minio:latest
|
image: quay.io/minio/minio:latest
|
||||||
container_name: banatie-storage
|
container_name: banatie-storage
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000" # S3 API
|
- "9000:9000" # S3 API
|
||||||
- "9001:9001" # Console
|
- "9001:9001" # Web Console
|
||||||
volumes:
|
volumes:
|
||||||
# SNMD: 4 drives for full S3 compatibility and erasure coding
|
# SNMD: 4 drives for full S3 compatibility and erasure coding
|
||||||
- ./data/storage/drive1:/data1
|
- ../data/storage/drive1:/data1
|
||||||
- ./data/storage/drive2:/data2
|
- ../data/storage/drive2:/data2
|
||||||
- ./data/storage/drive3:/data3
|
- ../data/storage/drive3:/data3
|
||||||
- ./data/storage/drive4:/data4
|
- ../data/storage/drive4:/data4
|
||||||
networks:
|
networks:
|
||||||
- banatie-network
|
- banatie-network
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -98,6 +101,7 @@ services:
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# MinIO Storage Initialization
|
||||||
storage-init:
|
storage-init:
|
||||||
image: minio/mc:latest
|
image: minio/mc:latest
|
||||||
container_name: banatie-storage-init
|
container_name: banatie-storage-init
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
# secrets.env.example
|
||||||
|
# Copy this file to secrets.env and fill with real values
|
||||||
|
# secrets.env is NOT tracked in git for security
|
||||||
|
|
||||||
|
# REQUIRED: Google Gemini API Key for image generation
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
|
# OPTIONAL: For testing purposes (usually generated via API)
|
||||||
|
# MASTER_KEY=will_be_generated_on_bootstrap
|
||||||
|
# API_KEY=will_be_generated_by_admin_endpoint
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
// Test filename sanitization for Unicode characters
|
||||||
|
|
||||||
|
function sanitizeFilename(filename) {
|
||||||
|
// Remove path traversal attempts FIRST from entire filename
|
||||||
|
let cleaned = filename.replace(/\.\./g, '').trim();
|
||||||
|
|
||||||
|
// Split filename and extension
|
||||||
|
const lastDotIndex = cleaned.lastIndexOf('.');
|
||||||
|
let baseName = lastDotIndex > 0 ? cleaned.substring(0, lastDotIndex) : cleaned;
|
||||||
|
const extension = lastDotIndex > 0 ? cleaned.substring(lastDotIndex) : '';
|
||||||
|
|
||||||
|
// Remove dangerous characters from base name
|
||||||
|
baseName = baseName
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Replace non-ASCII characters with ASCII equivalents or remove them
|
||||||
|
// This prevents S3 signature mismatches with MinIO
|
||||||
|
baseName = baseName
|
||||||
|
.normalize('NFD') // Decompose combined characters (é -> e + ´)
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
|
||||||
|
.replace(/[^\x20-\x7E]/g, '_') // Replace any remaining non-ASCII with underscore
|
||||||
|
.replace(/[^\w\s\-_.]/g, '_') // Replace special chars (except word chars, space, dash, underscore, dot) with underscore
|
||||||
|
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||||
|
.replace(/_{2,}/g, '_') // Collapse multiple underscores
|
||||||
|
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
|
||||||
|
|
||||||
|
// Ensure we still have a valid base name
|
||||||
|
if (baseName.length === 0) {
|
||||||
|
baseName = 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize extension (remove only dangerous chars, keep the dot)
|
||||||
|
let sanitizedExt = extension
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
|
||||||
|
.replace(/[^\x20-\x7E]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
// Ensure extension starts with a dot and is reasonable
|
||||||
|
if (sanitizedExt && !sanitizedExt.startsWith('.')) {
|
||||||
|
sanitizedExt = '.' + sanitizedExt;
|
||||||
|
}
|
||||||
|
if (sanitizedExt.length > 10) {
|
||||||
|
sanitizedExt = sanitizedExt.substring(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = baseName + sanitizedExt;
|
||||||
|
return result.substring(0, 255); // Limit total length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
const testCases = [
|
||||||
|
'Ущелье.png', // Cyrillic (Russian)
|
||||||
|
'测试文件.jpg', // Chinese
|
||||||
|
'test-file.png', // ASCII
|
||||||
|
'café-français.jpg', // French with accents
|
||||||
|
'🎉party🎊.gif', // Emoji
|
||||||
|
'test_مرحبا.webp', // Arabic
|
||||||
|
'file@#$%.png', // Special chars
|
||||||
|
'../../../etc/passwd', // Path traversal
|
||||||
|
'...hidden.txt', // Leading dots
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('Filename Sanitization Test Results:\n');
|
||||||
|
console.log('=' .repeat(80));
|
||||||
|
|
||||||
|
testCases.forEach(filename => {
|
||||||
|
const sanitized = sanitizeFilename(filename);
|
||||||
|
console.log(`Original: ${filename}`);
|
||||||
|
console.log(`Sanitized: ${sanitized}`);
|
||||||
|
console.log('-'.repeat(80));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue