Compare commits
No commits in common. "main" and "feature/placeholders-guide" have entirely different histories.
main
...
feature/pl
|
|
@ -1,42 +0,0 @@
|
||||||
---
|
|
||||||
name: frontend-design
|
|
||||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
|
||||||
license: Complete terms in LICENSE.txt
|
|
||||||
---
|
|
||||||
|
|
||||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
|
||||||
|
|
||||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
|
||||||
|
|
||||||
## Design Thinking
|
|
||||||
|
|
||||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
|
||||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
|
||||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
|
||||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
|
||||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
|
||||||
|
|
||||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
|
||||||
|
|
||||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
|
||||||
- Production-grade and functional
|
|
||||||
- Visually striking and memorable
|
|
||||||
- Cohesive with a clear aesthetic point-of-view
|
|
||||||
- Meticulously refined in every detail
|
|
||||||
|
|
||||||
## Frontend Aesthetics Guidelines
|
|
||||||
|
|
||||||
Focus on:
|
|
||||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
|
||||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
|
||||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
|
||||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
|
||||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
|
||||||
|
|
||||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
|
||||||
|
|
||||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
|
||||||
|
|
||||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
|
||||||
|
|
||||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
|
||||||
|
|
@ -86,7 +86,3 @@ tmp/
|
||||||
# Local Claude config (VPS-specific)
|
# Local Claude config (VPS-specific)
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
.env.prod
|
.env.prod
|
||||||
|
|
||||||
# Playwright
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,11 @@
|
||||||
"PERPLEXITY_TIMEOUT_MS": "600000"
|
"PERPLEXITY_TIMEOUT_MS": "600000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chrome-devtools": {
|
"browsermcp": {
|
||||||
|
"type": "stdio",
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "chrome-devtools-mcp@latest"]
|
"args": ["-y", "@browsermcp/mcp@latest"],
|
||||||
|
"env": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,7 @@ cdnRouter.get(
|
||||||
prompt,
|
prompt,
|
||||||
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||||
autoEnhance: normalizedAutoEnhance,
|
autoEnhance: normalizedAutoEnhance,
|
||||||
requestId: req.requestId,
|
requestId: `live-${scope}-${Date.now()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!generation.outputImage) {
|
if (!generation.outputImage) {
|
||||||
|
|
|
||||||
|
|
@ -63,14 +63,14 @@ uploadRouter.post(
|
||||||
`[${timestamp}] [${requestId}] Uploading file: ${file.originalname} as ${imageId} (${file.size} bytes)`,
|
`[${timestamp}] [${requestId}] Uploading file: ${file.originalname} as ${imageId} (${file.size} bytes)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadResult = await storageService.uploadFile({
|
const uploadResult = await storageService.uploadFile(
|
||||||
orgSlug,
|
orgSlug,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
imageId,
|
imageId,
|
||||||
buffer: file.buffer,
|
file.buffer,
|
||||||
contentType: file.mimetype,
|
file.mimetype,
|
||||||
originalFilename: file.originalname,
|
file.originalname,
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!uploadResult.success) {
|
if (!uploadResult.success) {
|
||||||
const errorResponse: UploadFileResponse = {
|
const errorResponse: UploadFileResponse = {
|
||||||
|
|
|
||||||
|
|
@ -197,16 +197,15 @@ imagesRouter.post(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const storageService = await StorageFactory.getInstance();
|
const storageService = await StorageFactory.getInstance();
|
||||||
const imageId = randomUUID();
|
|
||||||
|
|
||||||
const uploadResult = await storageService.uploadFile({
|
const uploadResult = await storageService.uploadFile(
|
||||||
orgSlug: orgId,
|
orgId,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
imageId,
|
'uploads',
|
||||||
buffer: file.buffer,
|
file.originalname,
|
||||||
contentType: file.mimetype,
|
file.buffer,
|
||||||
originalFilename: file.originalname,
|
file.mimetype,
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!uploadResult.success) {
|
if (!uploadResult.success) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,10 @@ import {
|
||||||
import { StorageFactory } from './StorageFactory';
|
import { StorageFactory } from './StorageFactory';
|
||||||
import { TTILogger, TTILogEntry } from './TTILogger';
|
import { TTILogger, TTILogEntry } from './TTILogger';
|
||||||
import { NetworkErrorDetector } from '../utils/NetworkErrorDetector';
|
import { NetworkErrorDetector } from '../utils/NetworkErrorDetector';
|
||||||
import { GeminiErrorDetector } from '../utils/GeminiErrorDetector';
|
|
||||||
import { ERROR_MESSAGES } from '../utils/constants/errors';
|
|
||||||
|
|
||||||
export class ImageGenService {
|
export class ImageGenService {
|
||||||
private ai: GoogleGenAI;
|
private ai: GoogleGenAI;
|
||||||
private primaryModel = 'gemini-2.5-flash-image';
|
private primaryModel = 'gemini-2.5-flash-image';
|
||||||
private static GEMINI_TIMEOUT_MS = 90_000; // 90 seconds
|
|
||||||
|
|
||||||
constructor(apiKey: string) {
|
constructor(apiKey: string) {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
|
|
@ -70,14 +67,14 @@ export class ImageGenService {
|
||||||
// Original filename for metadata (e.g., "my-image.png")
|
// Original filename for metadata (e.g., "my-image.png")
|
||||||
const originalFilename = `generated-image.${generatedData.fileExtension}`;
|
const originalFilename = `generated-image.${generatedData.fileExtension}`;
|
||||||
|
|
||||||
const uploadResult = await storageService.uploadFile({
|
const uploadResult = await storageService.uploadFile(
|
||||||
orgSlug: finalOrgSlug,
|
finalOrgSlug,
|
||||||
projectSlug: finalProjectSlug,
|
finalProjectSlug,
|
||||||
imageId,
|
imageId,
|
||||||
buffer: generatedData.buffer,
|
generatedData.buffer,
|
||||||
contentType: generatedData.mimeType,
|
generatedData.mimeType,
|
||||||
originalFilename,
|
originalFilename,
|
||||||
});
|
);
|
||||||
|
|
||||||
if (uploadResult.success) {
|
if (uploadResult.success) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -208,56 +205,18 @@ export class ImageGenService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the EXACT same config and contents objects calculated above
|
// Use the EXACT same config and contents objects calculated above
|
||||||
// Wrap with timeout to prevent hanging requests
|
const response = await this.ai.models.generateContent({
|
||||||
const response = await this.withTimeout(
|
|
||||||
this.ai.models.generateContent({
|
|
||||||
model: this.primaryModel,
|
model: this.primaryModel,
|
||||||
config,
|
config,
|
||||||
contents,
|
contents,
|
||||||
}),
|
});
|
||||||
ImageGenService.GEMINI_TIMEOUT_MS,
|
|
||||||
'Gemini image generation'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log response structure for debugging
|
// Parse response
|
||||||
GeminiErrorDetector.logResponseStructure(response as any);
|
if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) {
|
||||||
|
throw new Error('No response received from Gemini AI');
|
||||||
// Check promptFeedback for blocked prompts FIRST
|
|
||||||
if ((response as any).promptFeedback?.blockReason) {
|
|
||||||
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] Prompt blocked:`,
|
|
||||||
GeminiErrorDetector.formatForLogging(errorResult!)
|
|
||||||
);
|
|
||||||
throw new Error(errorResult!.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have candidates
|
const content = response.candidates[0].content;
|
||||||
if (!response.candidates || !response.candidates[0]) {
|
|
||||||
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
|
|
||||||
console.error(`[ImageGenService] No candidates in response`);
|
|
||||||
throw new Error(errorResult?.message || 'No response candidates from Gemini AI');
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = response.candidates[0];
|
|
||||||
|
|
||||||
// Check finishReason for non-STOP completions
|
|
||||||
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
|
|
||||||
const errorResult = GeminiErrorDetector.analyzeResponse(response as any);
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] Non-STOP finish reason:`,
|
|
||||||
GeminiErrorDetector.formatForLogging(errorResult!)
|
|
||||||
);
|
|
||||||
throw new Error(errorResult!.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check content exists
|
|
||||||
if (!candidate.content) {
|
|
||||||
console.error(`[ImageGenService] No content in candidate`);
|
|
||||||
throw new Error('No content in Gemini AI response');
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = candidate.content;
|
|
||||||
let generatedDescription: string | undefined;
|
let generatedDescription: string | undefined;
|
||||||
let imageData: { buffer: Buffer; mimeType: string } | null = null;
|
let imageData: { buffer: Buffer; mimeType: string } | null = null;
|
||||||
|
|
||||||
|
|
@ -273,14 +232,7 @@ export class ImageGenService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!imageData) {
|
if (!imageData) {
|
||||||
// Log what we got instead of image
|
throw new Error('No image data received from Gemini AI');
|
||||||
const partTypes = (content.parts || []).map((p: any) =>
|
|
||||||
p.inlineData ? 'image' : p.text ? 'text' : 'other'
|
|
||||||
);
|
|
||||||
console.error(`[ImageGenService] No image data in response. Parts: [${partTypes.join(', ')}]`);
|
|
||||||
throw new Error(
|
|
||||||
`${ERROR_MESSAGES.GEMINI_NO_IMAGE}. Response contained: ${partTypes.join(', ') || 'nothing'}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
|
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
|
||||||
|
|
@ -312,38 +264,6 @@ export class ImageGenService {
|
||||||
geminiParams,
|
geminiParams,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check for rate limit (HTTP 429)
|
|
||||||
const err = error as { status?: number; message?: string };
|
|
||||||
if (err.status === 429) {
|
|
||||||
const geminiError = GeminiErrorDetector.classifyApiError(error);
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] Rate limit:`,
|
|
||||||
GeminiErrorDetector.formatForLogging(geminiError)
|
|
||||||
);
|
|
||||||
throw new Error(geminiError.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for timeout
|
|
||||||
if (error instanceof Error && error.message.includes('timed out')) {
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] Timeout after ${ImageGenService.GEMINI_TIMEOUT_MS}ms:`,
|
|
||||||
error.message
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`${ERROR_MESSAGES.GEMINI_TIMEOUT} after ${ImageGenService.GEMINI_TIMEOUT_MS / 1000} seconds`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for other API errors with status codes
|
|
||||||
if (err.status) {
|
|
||||||
const geminiError = GeminiErrorDetector.classifyApiError(error);
|
|
||||||
console.error(
|
|
||||||
`[ImageGenService] API error:`,
|
|
||||||
GeminiErrorDetector.formatForLogging(geminiError)
|
|
||||||
);
|
|
||||||
throw new Error(geminiError.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced error detection with network diagnostics
|
// Enhanced error detection with network diagnostics
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
// Classify the error and check for network issues (only on failure)
|
// Classify the error and check for network issues (only on failure)
|
||||||
|
|
@ -359,32 +279,6 @@ export class ImageGenService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap a promise with timeout
|
|
||||||
*/
|
|
||||||
private async withTimeout<T>(
|
|
||||||
promise: Promise<T>,
|
|
||||||
timeoutMs: number,
|
|
||||||
operationName: string
|
|
||||||
): Promise<T> {
|
|
||||||
let timeoutId: NodeJS.Timeout;
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
reject(new Error(`${operationName} timed out after ${timeoutMs}ms`));
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await Promise.race([promise, timeoutPromise]);
|
|
||||||
clearTimeout(timeoutId!);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeoutId!);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static validateReferenceImages(files: Express.Multer.File[]): {
|
static validateReferenceImages(files: Express.Multer.File[]): {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Client as MinioClient } from 'minio';
|
import { Client as MinioClient } from 'minio';
|
||||||
import { StorageService, FileMetadata, UploadResult, UploadFileParams } from './StorageService';
|
import { StorageService, FileMetadata, UploadResult } from './StorageService';
|
||||||
|
|
||||||
export class MinioStorageService implements StorageService {
|
export class MinioStorageService implements StorageService {
|
||||||
private client: MinioClient;
|
private client: MinioClient;
|
||||||
|
|
@ -102,9 +102,14 @@ export class MinioStorageService implements StorageService {
|
||||||
return await this.client.bucketExists(this.bucketName);
|
return await this.client.bucketExists(this.bucketName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(params: UploadFileParams): Promise<UploadResult> {
|
async uploadFile(
|
||||||
const { orgSlug, projectSlug, imageId, buffer, contentType, originalFilename } = params;
|
orgSlug: string,
|
||||||
|
projectSlug: string,
|
||||||
|
imageId: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
contentType: string,
|
||||||
|
originalFilename?: string,
|
||||||
|
): Promise<UploadResult> {
|
||||||
// Validate inputs first
|
// Validate inputs first
|
||||||
this.validatePath(orgSlug, projectSlug, imageId);
|
this.validatePath(orgSlug, projectSlug, imageId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,6 @@ export interface UploadResult {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadFileParams {
|
|
||||||
orgSlug: string;
|
|
||||||
projectSlug: string;
|
|
||||||
imageId: string;
|
|
||||||
buffer: Buffer;
|
|
||||||
contentType: string;
|
|
||||||
originalFilename?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageService {
|
export interface StorageService {
|
||||||
/**
|
/**
|
||||||
* Create the main bucket if it doesn't exist
|
* Create the main bucket if it doesn't exist
|
||||||
|
|
@ -44,8 +35,22 @@ export interface StorageService {
|
||||||
/**
|
/**
|
||||||
* Upload a file to storage
|
* Upload a file to storage
|
||||||
* Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
* Path format: {orgSlug}/{projectSlug}/img/{imageId}
|
||||||
|
*
|
||||||
|
* @param orgSlug Organization slug
|
||||||
|
* @param projectSlug Project slug
|
||||||
|
* @param imageId UUID for the file (same as image.id in DB)
|
||||||
|
* @param buffer File buffer
|
||||||
|
* @param contentType MIME type
|
||||||
|
* @param originalFilename Original filename from user (for metadata)
|
||||||
*/
|
*/
|
||||||
uploadFile(params: UploadFileParams): Promise<UploadResult>;
|
uploadFile(
|
||||||
|
orgSlug: string,
|
||||||
|
projectSlug: string,
|
||||||
|
imageId: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
contentType: string,
|
||||||
|
originalFilename?: string,
|
||||||
|
): Promise<UploadResult>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a file from storage
|
* Download a file from storage
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,6 @@ export interface ImageGenerationResult {
|
||||||
model: string;
|
model: string;
|
||||||
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
|
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
|
||||||
error?: string;
|
error?: string;
|
||||||
errorCode?: string; // Gemini-specific error code (GEMINI_RATE_LIMIT, GEMINI_TIMEOUT, etc.)
|
|
||||||
errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors
|
errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors
|
||||||
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
|
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
import { ERROR_CODES, ERROR_MESSAGES } from './constants/errors';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of Gemini error analysis
|
|
||||||
*/
|
|
||||||
export interface GeminiErrorResult {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
finishReason?: string | undefined;
|
|
||||||
blockReason?: string | undefined;
|
|
||||||
safetyCategories?: string[] | undefined;
|
|
||||||
retryAfter?: number | undefined;
|
|
||||||
httpStatus?: number | undefined;
|
|
||||||
technicalDetails?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safety rating from Gemini response
|
|
||||||
*/
|
|
||||||
interface SafetyRating {
|
|
||||||
category?: string;
|
|
||||||
probability?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gemini response structure (partial)
|
|
||||||
*/
|
|
||||||
interface GeminiResponse {
|
|
||||||
candidates?: Array<{
|
|
||||||
finishReason?: string;
|
|
||||||
finishMessage?: string;
|
|
||||||
content?: {
|
|
||||||
parts?: Array<{
|
|
||||||
text?: string;
|
|
||||||
inlineData?: { data?: string; mimeType?: string };
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
safetyRatings?: SafetyRating[];
|
|
||||||
}>;
|
|
||||||
promptFeedback?: {
|
|
||||||
blockReason?: string;
|
|
||||||
blockReasonMessage?: string;
|
|
||||||
safetyRatings?: SafetyRating[];
|
|
||||||
};
|
|
||||||
usageMetadata?: {
|
|
||||||
promptTokenCount?: number;
|
|
||||||
candidatesTokenCount?: number;
|
|
||||||
totalTokenCount?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detector for Gemini AI specific errors
|
|
||||||
* Provides detailed error classification for rate limits, safety blocks, timeouts, etc.
|
|
||||||
*/
|
|
||||||
export class GeminiErrorDetector {
|
|
||||||
/**
|
|
||||||
* Classify an API-level error (HTTP errors from Gemini)
|
|
||||||
*/
|
|
||||||
static classifyApiError(error: unknown): GeminiErrorResult {
|
|
||||||
const err = error as { status?: number; message?: string; details?: unknown };
|
|
||||||
|
|
||||||
// Check for rate limit (HTTP 429)
|
|
||||||
if (err.status === 429) {
|
|
||||||
const retryAfter = this.extractRetryAfter(error);
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_RATE_LIMIT,
|
|
||||||
message: retryAfter
|
|
||||||
? `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Retry after ${retryAfter} seconds.`
|
|
||||||
: `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Please wait before retrying.`,
|
|
||||||
httpStatus: 429,
|
|
||||||
retryAfter,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for authentication errors
|
|
||||||
if (err.status === 401 || err.status === 403) {
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: 'Gemini API authentication failed. Check API key.',
|
|
||||||
httpStatus: err.status,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for server errors
|
|
||||||
if (err.status === 500 || err.status === 503) {
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: 'Gemini API service temporarily unavailable.',
|
|
||||||
httpStatus: err.status,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for bad request
|
|
||||||
if (err.status === 400) {
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: `Gemini API invalid request: ${err.message || 'Unknown error'}`,
|
|
||||||
httpStatus: 400,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic API error
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: err.message || ERROR_MESSAGES.GEMINI_API_ERROR,
|
|
||||||
httpStatus: err.status,
|
|
||||||
technicalDetails: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze a Gemini response for errors (finishReason, blockReason)
|
|
||||||
* Returns null if no error detected
|
|
||||||
*/
|
|
||||||
static analyzeResponse(response: GeminiResponse): GeminiErrorResult | null {
|
|
||||||
// Check promptFeedback for blocked prompts
|
|
||||||
if (response.promptFeedback?.blockReason) {
|
|
||||||
const safetyCategories = this.extractSafetyCategories(
|
|
||||||
response.promptFeedback.safetyRatings
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
|
||||||
message:
|
|
||||||
response.promptFeedback.blockReasonMessage ||
|
|
||||||
`Prompt blocked: ${response.promptFeedback.blockReason}`,
|
|
||||||
blockReason: response.promptFeedback.blockReason,
|
|
||||||
safetyCategories,
|
|
||||||
technicalDetails: `blockReason: ${response.promptFeedback.blockReason}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check candidate finishReason
|
|
||||||
const candidate = response.candidates?.[0];
|
|
||||||
if (!candidate) {
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_NO_IMAGE,
|
|
||||||
message: 'No response candidates from Gemini AI.',
|
|
||||||
technicalDetails: 'response.candidates is empty or undefined',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const finishReason = candidate.finishReason;
|
|
||||||
|
|
||||||
// STOP is normal completion
|
|
||||||
if (!finishReason || finishReason === 'STOP') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different finishReasons
|
|
||||||
switch (finishReason) {
|
|
||||||
case 'SAFETY':
|
|
||||||
case 'IMAGE_SAFETY': {
|
|
||||||
const safetyCategories = this.extractSafetyCategories(candidate.safetyRatings);
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_SAFETY_BLOCK,
|
|
||||||
message: `Content blocked due to safety: ${safetyCategories.join(', ') || 'unspecified'}`,
|
|
||||||
finishReason,
|
|
||||||
safetyCategories,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}, safetyRatings: ${JSON.stringify(candidate.safetyRatings)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'NO_IMAGE':
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_NO_IMAGE,
|
|
||||||
message: 'Gemini AI could not generate an image for this prompt. Try rephrasing.',
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'IMAGE_PROHIBITED_CONTENT':
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
|
||||||
message: 'Image generation blocked due to prohibited content in prompt.',
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'MAX_TOKENS':
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: 'Response exceeded maximum token limit. Try a shorter prompt.',
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'RECITATION':
|
|
||||||
case 'IMAGE_RECITATION':
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
|
||||||
message: 'Response blocked due to potential copyright concerns.',
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
||||||
message: `Generation stopped unexpectedly: ${finishReason}`,
|
|
||||||
finishReason,
|
|
||||||
technicalDetails: `finishReason: ${finishReason}, finishMessage: ${candidate.finishMessage}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if response has image data
|
|
||||||
*/
|
|
||||||
static hasImageData(response: GeminiResponse): boolean {
|
|
||||||
const parts = response.candidates?.[0]?.content?.parts;
|
|
||||||
if (!parts) return false;
|
|
||||||
return parts.some((part) => part.inlineData?.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format error result for logging
|
|
||||||
*/
|
|
||||||
static formatForLogging(result: GeminiErrorResult): string {
|
|
||||||
const parts = [`[${result.code}] ${result.message}`];
|
|
||||||
|
|
||||||
if (result.finishReason) {
|
|
||||||
parts.push(`finishReason=${result.finishReason}`);
|
|
||||||
}
|
|
||||||
if (result.blockReason) {
|
|
||||||
parts.push(`blockReason=${result.blockReason}`);
|
|
||||||
}
|
|
||||||
if (result.httpStatus) {
|
|
||||||
parts.push(`httpStatus=${result.httpStatus}`);
|
|
||||||
}
|
|
||||||
if (result.retryAfter) {
|
|
||||||
parts.push(`retryAfter=${result.retryAfter}s`);
|
|
||||||
}
|
|
||||||
if (result.safetyCategories?.length) {
|
|
||||||
parts.push(`safety=[${result.safetyCategories.join(', ')}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log Gemini response structure for debugging
|
|
||||||
*/
|
|
||||||
static logResponseStructure(response: GeminiResponse, prefix: string = ''): void {
|
|
||||||
const parts = response.candidates?.[0]?.content?.parts || [];
|
|
||||||
const partTypes = parts.map((p) => {
|
|
||||||
if (p.inlineData) return 'image';
|
|
||||||
if (p.text) return 'text';
|
|
||||||
return 'other';
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[ImageGenService]${prefix ? ` [${prefix}]` : ''} Gemini response:`, {
|
|
||||||
hasCandidates: !!response.candidates?.length,
|
|
||||||
candidateCount: response.candidates?.length || 0,
|
|
||||||
finishReason: response.candidates?.[0]?.finishReason || null,
|
|
||||||
blockReason: response.promptFeedback?.blockReason || null,
|
|
||||||
partsCount: parts.length,
|
|
||||||
partTypes,
|
|
||||||
usageMetadata: response.usageMetadata || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract retry-after value from error
|
|
||||||
*/
|
|
||||||
private static extractRetryAfter(error: unknown): number | undefined {
|
|
||||||
const err = error as { headers?: { get?: (key: string) => string | null } };
|
|
||||||
|
|
||||||
// Try to get from headers
|
|
||||||
if (err.headers?.get) {
|
|
||||||
const retryAfter = err.headers.get('retry-after');
|
|
||||||
if (retryAfter) {
|
|
||||||
const seconds = parseInt(retryAfter, 10);
|
|
||||||
if (!isNaN(seconds)) return seconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default retry after for rate limits
|
|
||||||
return 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract safety category names from ratings
|
|
||||||
*/
|
|
||||||
private static extractSafetyCategories(ratings?: SafetyRating[]): string[] {
|
|
||||||
if (!ratings || ratings.length === 0) return [];
|
|
||||||
|
|
||||||
// Filter for high/medium probability ratings and extract category names
|
|
||||||
return ratings
|
|
||||||
.filter((r) => r.probability === 'HIGH' || r.probability === 'MEDIUM')
|
|
||||||
.map((r) => r.category?.replace('HARM_CATEGORY_', '') || 'UNKNOWN')
|
|
||||||
.filter((c) => c !== 'UNKNOWN');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -51,14 +51,6 @@ export const ERROR_MESSAGES = {
|
||||||
INTERNAL_SERVER_ERROR: 'Internal server error',
|
INTERNAL_SERVER_ERROR: 'Internal server error',
|
||||||
INVALID_REQUEST: 'Invalid request',
|
INVALID_REQUEST: 'Invalid request',
|
||||||
OPERATION_FAILED: 'Operation failed',
|
OPERATION_FAILED: 'Operation failed',
|
||||||
|
|
||||||
// Gemini AI Errors
|
|
||||||
GEMINI_RATE_LIMIT: 'Gemini API rate limit exceeded',
|
|
||||||
GEMINI_CONTENT_BLOCKED: 'Content blocked by Gemini safety filters',
|
|
||||||
GEMINI_TIMEOUT: 'Gemini API request timed out',
|
|
||||||
GEMINI_NO_IMAGE: 'Gemini AI could not generate image',
|
|
||||||
GEMINI_SAFETY_BLOCK: 'Content blocked due to safety concerns',
|
|
||||||
GEMINI_API_ERROR: 'Gemini API returned an error',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ERROR_CODES = {
|
export const ERROR_CODES = {
|
||||||
|
|
@ -117,14 +109,6 @@ export const ERROR_CODES = {
|
||||||
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||||
INVALID_REQUEST: 'INVALID_REQUEST',
|
INVALID_REQUEST: 'INVALID_REQUEST',
|
||||||
OPERATION_FAILED: 'OPERATION_FAILED',
|
OPERATION_FAILED: 'OPERATION_FAILED',
|
||||||
|
|
||||||
// Gemini AI Errors
|
|
||||||
GEMINI_RATE_LIMIT: 'GEMINI_RATE_LIMIT',
|
|
||||||
GEMINI_CONTENT_BLOCKED: 'GEMINI_CONTENT_BLOCKED',
|
|
||||||
GEMINI_TIMEOUT: 'GEMINI_TIMEOUT',
|
|
||||||
GEMINI_NO_IMAGE: 'GEMINI_NO_IMAGE',
|
|
||||||
GEMINI_SAFETY_BLOCK: 'GEMINI_SAFETY_BLOCK',
|
|
||||||
GEMINI_API_ERROR: 'GEMINI_API_ERROR',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
|
@ -120,7 +120,7 @@ export default function PlaceholderImagesGuidePage() {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-400 text-sm mt-3">
|
<p className="text-gray-400 text-sm mt-3">
|
||||||
<a href="/docs/generation/#prompt-templates" className="text-purple-400 hover:underline">
|
<a href="/docs/generation/#templates" className="text-purple-400 hover:underline">
|
||||||
View all templates →
|
View all templates →
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,445 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en"><head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
|
||||||
<title>Refined Technical Blog Article</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,container-queries"></script>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"/>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: "class",
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: "#8b5cf6",
|
|
||||||
"background-light": "#ffffff",
|
|
||||||
"background-dark": "#0B0F19",
|
|
||||||
"surface-light": "#f3f4f6",
|
|
||||||
"surface-dark": "#111827",
|
|
||||||
"border-light": "#e5e7eb",
|
|
||||||
"border-dark": "#1f2937",
|
|
||||||
"text-main-light": "#111827",
|
|
||||||
"text-main-dark": "#f9fafb",
|
|
||||||
"text-muted-light": "#6b7280",
|
|
||||||
"text-muted-dark": "#9ca3af",
|
|
||||||
// New colors for the dark sidebar components
|
|
||||||
"card-dark": "#13141f",
|
|
||||||
"card-border": "#2d2e3e",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
display: ["Inter", "sans-serif"],
|
|
||||||
body: ["Inter", "sans-serif"],
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
DEFAULT: "0.5rem",
|
|
||||||
},
|
|
||||||
typography: (theme) => ({
|
|
||||||
DEFAULT: {
|
|
||||||
css: {
|
|
||||||
color: theme('colors.text-main-light'),
|
|
||||||
a: {
|
|
||||||
color: theme('colors.primary'),
|
|
||||||
'&:hover': {
|
|
||||||
color: '#7c3aed',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
h1: { color: theme('colors.text-main-light') },
|
|
||||||
h2: { color: theme('colors.text-main-light') },
|
|
||||||
h3: { color: theme('colors.text-main-light') },
|
|
||||||
h4: { color: theme('colors.text-main-light') },
|
|
||||||
strong: { color: theme('colors.text-main-light') },
|
|
||||||
code: { color: theme('colors.primary') },
|
|
||||||
blockquote: {
|
|
||||||
borderLeftColor: theme('colors.primary'),
|
|
||||||
fontStyle: 'italic',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
css: {
|
|
||||||
color: theme('colors.text-main-dark'),
|
|
||||||
a: {
|
|
||||||
color: '#a78bfa',
|
|
||||||
'&:hover': {
|
|
||||||
color: '#c4b5fd',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
h1: { color: theme('colors.text-main-dark') },
|
|
||||||
h2: { color: theme('colors.text-main-dark') },
|
|
||||||
h3: { color: theme('colors.text-main-dark') },
|
|
||||||
h4: { color: theme('colors.text-main-dark') },
|
|
||||||
strong: { color: theme('colors.text-main-dark') },
|
|
||||||
code: { color: theme('colors.primary') },
|
|
||||||
blockquote: { borderLeftColor: theme('colors.primary') },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: #1f2937;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: #4b5563;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #6b7280;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-background-light text-text-main-light antialiased transition-colors duration-200">
|
|
||||||
<nav class="sticky top-0 z-50 w-full border-b border-white/10 bg-[#0B0F19] text-white">
|
|
||||||
<div class="container mx-auto flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="flex items-center gap-2 font-bold text-xl tracking-tight">
|
|
||||||
<div class="h-8 w-8 rounded-lg bg-gradient-to-br from-primary to-pink-500 flex items-center justify-center">
|
|
||||||
<span class="material-icons text-white text-lg">auto_awesome</span>
|
|
||||||
</div>
|
|
||||||
<span>Banatie</span>
|
|
||||||
</div>
|
|
||||||
<div class="hidden md:flex ml-10 space-x-6 text-sm font-medium text-gray-300">
|
|
||||||
<a class="hover:text-white transition-colors" href="#">Product</a>
|
|
||||||
<a class="hover:text-white transition-colors" href="#">Solutions</a>
|
|
||||||
<a class="text-white" href="#">Blog</a>
|
|
||||||
<a class="hover:text-white transition-colors" href="#">Docs</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<a class="hidden sm:inline-flex items-center justify-center rounded-lg bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background-dark" href="#">
|
|
||||||
Log in
|
|
||||||
</a>
|
|
||||||
<a class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-violet-600 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background-dark" href="#">
|
|
||||||
Get Access
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<header class="relative overflow-hidden bg-background-dark text-white pt-12 pb-16 lg:pt-20 lg:pb-24">
|
|
||||||
<div class="absolute inset-0 z-0">
|
|
||||||
<div class="absolute -top-24 -left-24 w-96 h-96 bg-primary/20 rounded-full blur-3xl"></div>
|
|
||||||
<div class="absolute top-1/2 right-0 w-[500px] h-[500px] bg-pink-600/10 rounded-full blur-3xl transform translate-x-1/3 -translate-y-1/2"></div>
|
|
||||||
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1635070041078-e363dbe005cb?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center opacity-10 mix-blend-overlay pointer-events-none"></div>
|
|
||||||
</div>
|
|
||||||
<div class="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="lg:grid lg:grid-cols-12 lg:gap-16 items-center">
|
|
||||||
<div class="lg:col-span-7 mb-12 lg:mb-0">
|
|
||||||
<nav aria-label="Breadcrumb" class="flex mb-8 text-xs text-gray-400 font-medium">
|
|
||||||
<ol class="inline-flex items-center space-x-1 md:space-x-2">
|
|
||||||
<li class="inline-flex items-center">
|
|
||||||
<a class="hover:text-white transition-colors" href="#">Blog</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="material-icons text-[14px] text-gray-600 mx-1">chevron_right</span>
|
|
||||||
<a class="hover:text-white transition-colors" href="#">Engineering</a>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li aria-current="page">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="material-icons text-[14px] text-gray-600 mx-1">chevron_right</span>
|
|
||||||
<span class="text-gray-200">Optimizing Image Generation Pipelines</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<div class="flex items-center gap-2 mb-8">
|
|
||||||
<span class="inline-flex items-center rounded-full bg-primary/20 px-3 py-1 text-xs font-medium text-primary ring-1 ring-inset ring-primary/30">
|
|
||||||
Engineering
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-400 text-sm flex items-center gap-1 ml-2">
|
|
||||||
<span class="material-icons text-[16px]">schedule</span> 8 min read
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-extrabold tracking-tight mb-8 leading-tight text-white">
|
|
||||||
Optimizing Image Generation Pipelines at Scale
|
|
||||||
</h1>
|
|
||||||
<p class="text-lg sm:text-xl text-gray-300 mb-10 max-w-2xl leading-relaxed">
|
|
||||||
Learn how we reduced latency by 40% using edge caching and predictive pre-generation strategies for our high-throughput API endpoints.
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-4 border-t border-white/10 pt-8">
|
|
||||||
<img alt="Author Avatar" class="h-12 w-12 rounded-full ring-2 ring-background-dark object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuB7lcPRCoZjsFsjDPuss98IvtV47CsxB3edKZH1Jy8D7TtC52cTc1lpxd6PZcqHk3lZWGFU5P-8tUB4xVMImKueltROJN-34JuWGPTdU-hEY8Z2r3ooKCANBoeB4QkCv3iZwpjpuwQlz_LJuMRCdiSJwmAfIv839cg90Lw50ekECfdKsH_zdM8g4Ig3oDsHB8sxcdoNbgZXLGdJ5K-P2QhA8FhKI9RBmvtGCLndihNZdRw405BTYJBYoQORCZ0qMfCmggjeD8Nbx2g"/>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-white text-base">Alex Chen</div>
|
|
||||||
<div class="text-sm text-gray-400">Senior Infrastructure Engineer • Oct 24, 2023</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="lg:col-span-5 relative">
|
|
||||||
<div class="relative rounded-xl overflow-hidden shadow-2xl ring-1 ring-white/10 bg-black/40 backdrop-blur-sm">
|
|
||||||
<img alt="Abstract technical graphic showing network nodes" class="w-full h-auto object-cover aspect-[4/3] mix-blend-lighten opacity-90" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBQduWhcJrwC_QkSUkZ4bCXD5uh4Co2BxXWYMoN8DgTfrdRDQhMNRXYyPA-aEIkLj61sxdw64W-HhLZU8RGNh_YZ5AV2mZDgI5LArVucyhwJdotgRIDJ-oZDZYXHpD25WfsQiZVYKyDlDKBja610LlPzPJmWKOII3MbybkXab1D9xr93TEJ-AoDxFc7j2Bc_ylOKyqVfTLshdwDQDJNAVbnA-H6AavvVbnMyBUdMnFEnW-lVXROEE0mxhvwTyBqEjf68BMoqrr8sGo"/>
|
|
||||||
<div class="absolute bottom-6 left-6 z-20 bg-gray-900/90 backdrop-blur border border-white/10 rounded-lg p-3 shadow-xl max-w-[280px]">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<div class="w-2.5 h-2.5 rounded-full bg-red-500"></div>
|
|
||||||
<div class="w-2.5 h-2.5 rounded-full bg-yellow-500"></div>
|
|
||||||
<div class="w-2.5 h-2.5 rounded-full bg-green-500"></div>
|
|
||||||
</div>
|
|
||||||
<div class="font-mono text-xs text-green-400">
|
|
||||||
$ latency --check<br/>
|
|
||||||
> 45ms (optimized)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="bg-background-light border-t-0 -mt-1 pt-12 lg:pt-16 pb-12">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="lg:grid lg:grid-cols-12 lg:gap-12">
|
|
||||||
<aside class="hidden lg:block lg:col-span-1">
|
|
||||||
<div class="sticky top-28 flex flex-col gap-4 items-center">
|
|
||||||
<button aria-label="Share on Twitter" class="p-2 rounded-full bg-white text-gray-500 hover:text-primary transition-colors border border-gray-200 shadow-sm group">
|
|
||||||
<svg class="w-5 h-5 fill-current" viewBox="0 0 24 24"><path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"></path></svg>
|
|
||||||
</button>
|
|
||||||
<button aria-label="Share on LinkedIn" class="p-2 rounded-full bg-white text-gray-500 hover:text-blue-600 transition-colors border border-gray-200 shadow-sm">
|
|
||||||
<svg class="w-5 h-5 fill-current" viewBox="0 0 24 24"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"></path></svg>
|
|
||||||
</button>
|
|
||||||
<button aria-label="Copy Link" class="p-2 rounded-full bg-white text-gray-500 hover:text-gray-900 transition-colors border border-gray-200 shadow-sm">
|
|
||||||
<span class="material-icons text-[20px]">link</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<div class="lg:col-span-8">
|
|
||||||
<article class="prose prose-lg prose-slate max-w-none prose-headings:font-bold prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-xl">
|
|
||||||
<p class="lead text-xl text-gray-600 mb-8 font-light">
|
|
||||||
When we first launched Banatie's image generation API, we optimized for quality. But as our user base grew, so did the demand for speed. Here is how we tackled the challenge of delivering AI-generated assets in milliseconds.
|
|
||||||
</p>
|
|
||||||
<h2>The Latency Bottleneck</h2>
|
|
||||||
<p>
|
|
||||||
Our initial architecture was straightforward: a request hits our API gateway, gets queued, processed by a GPU worker, and the resulting image is uploaded to S3. Simple, but slow.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Users integrating our API into real-time applications needed <a href="#">faster response times</a>. We identified two main areas for improvement:
|
|
||||||
</p>
|
|
||||||
<ul class="marker:text-primary">
|
|
||||||
<li><strong>Cold Starts:</strong> Spinning up new GPU instances took 2-3 minutes.</li>
|
|
||||||
<li><strong>Network Overhead:</strong> Round trips between the inference server and storage added 200ms+.</li>
|
|
||||||
</ul>
|
|
||||||
<div class="my-8 rounded-lg border-l-4 border-blue-500 bg-blue-50 p-6 shadow-sm">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="flex-shrink-0 mt-1">
|
|
||||||
<span class="material-icons text-blue-600">info</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 class="font-bold text-gray-900 mt-0 mb-2">Pro Tip: Analyze your P99</h5>
|
|
||||||
<p class="text-sm text-gray-700 m-0 leading-relaxed">
|
|
||||||
Don't just look at average latency. Your P99 (99th percentile) latency tells you the experience of your users during worst-case scenarios. Optimizing for P99 often yields the most stable system.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2>Implementing Edge Caching</h2>
|
|
||||||
<p>
|
|
||||||
To solve the network overhead, we moved our delivery layer to the edge. By utilizing a global CDN, we could serve cached results instantly for repeated prompts.
|
|
||||||
</p>
|
|
||||||
<div class="my-8 overflow-hidden rounded-xl border border-gray-200 bg-[#1e1e1e] shadow-xl">
|
|
||||||
<div class="flex items-center justify-between border-b border-white/5 bg-[#252526] px-4 py-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="h-3 w-3 rounded-full bg-[#ff5f56]"></div>
|
|
||||||
<div class="h-3 w-3 rounded-full bg-[#ffbd2e]"></div>
|
|
||||||
<div class="h-3 w-3 rounded-full bg-[#27c93f]"></div>
|
|
||||||
</div>
|
|
||||||
<span class="ml-4 text-xs font-mono text-gray-400">middleware/cache-control.ts</span>
|
|
||||||
<div class="flex-grow"></div>
|
|
||||||
<button class="text-xs text-gray-400 hover:text-white transition-colors">Copy</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 overflow-x-auto custom-scrollbar">
|
|
||||||
<pre class="font-mono text-sm leading-relaxed text-[#d4d4d4] m-0 p-0 bg-transparent"><code><span class="text-[#c586c0]">export function</span> <span class="text-[#dcdcaa]">setCacheHeaders</span>(res: Response) {
|
|
||||||
<span class="text-[#6a9955]">// Cache for 1 hour at the edge, validate stale in background</span>
|
|
||||||
res.<span class="text-[#dcdcaa]">setHeader</span>(
|
|
||||||
<span class="text-[#ce9178]'Cache-Control'</span>, <span class=" s-maxage="3600," stale-while-revalidate="600'</span" text-[#ce9178]'public,="">
|
|
||||||
);
|
|
||||||
<span class="text-[#6a9955]">// Custom tag for purging</span>
|
|
||||||
res.<span class="text-[#dcdcaa]">setHeader</span>(<span class="text-[#ce9178]'Surrogate-Key'</span>, <span class=" span="" text-[#ce9178]'gen-api-v1'<="">);
|
|
||||||
}</span></span></code></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3>The Results</h3>
|
|
||||||
<p>
|
|
||||||
After deploying these changes, we saw a dramatic drop in TTFB (Time To First Byte).
|
|
||||||
</p>
|
|
||||||
<blockquote class="my-10 border-l-4 border-primary bg-gray-50 p-6 text-xl italic font-medium leading-relaxed text-gray-800 shadow-sm rounded-r-lg">
|
|
||||||
"The latency improvements were immediate. Our dashboard loads felt instantaneous compared to the previous version, directly impacting our user retention metrics."
|
|
||||||
</blockquote>
|
|
||||||
<figure class="my-10 group">
|
|
||||||
<div class="overflow-hidden rounded-xl border border-gray-200 shadow-lg transition-all duration-300 hover:shadow-xl">
|
|
||||||
<img alt="Graph comparing latency before and after optimization" class="w-full h-auto object-cover transform transition-transform duration-500 group-hover:scale-[1.02]" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBEgg1FA9f6Km5tYQk_92Az_mAXuc6G9ps8KamUSB_VMXwrhcFJLCpgJe7doa6ZdFLQkzJhAcT2OB_E69yQLWyKEPm7Oni0f9YV2_XjH5-jgfAMsv95vBD5r-o35be_5UmmD8-lY40hslbOB075pmwCZ56ISj5VKQARpU5s1zi1nBQvsXWK-5QywJOLp0X8VDhYlB-igMlqCGLhZh5AJ4ufr9hamWVmBiCBa__p7S_hKHjpMxbxs0Qhow_bFjM2vb2eAiUtx3wQjGI"/>
|
|
||||||
</div>
|
|
||||||
<figcaption class="mt-4 flex items-center justify-center gap-2 text-sm text-gray-500">
|
|
||||||
<span class="material-icons text-[16px]">insert_chart</span>
|
|
||||||
<span>Latency reduction over a 24-hour period post-deployment</span>
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
<h2>Predictive Pre-Generation</h2>
|
|
||||||
<p>
|
|
||||||
For our enterprise clients, we introduced predictive generation. By analyzing usage patterns, we can pre-warm the cache with variations of commonly requested assets before the user even asks for them.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
This is particularly useful for e-commerce clients who update their catalogs at predictable times.
|
|
||||||
</p>
|
|
||||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
|
||||||
<h3 class="text-lg font-semibold mb-4 text-gray-900">Conclusion</h3>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Optimization is never finished. We are currently exploring WebAssembly for client-side resizing to further offload our servers. Stay tuned for Part 2!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<aside class="hidden lg:block lg:col-span-3">
|
|
||||||
<div class="sticky top-28 space-y-8">
|
|
||||||
<div class="rounded-xl bg-gray-50 border border-gray-200 p-5 shadow-sm">
|
|
||||||
<h4 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4 border-b border-gray-200 pb-2">
|
|
||||||
On This Page
|
|
||||||
</h4>
|
|
||||||
<nav class="flex flex-col space-y-3 text-sm">
|
|
||||||
<a class="text-gray-900 font-medium pl-2 border-l-2 border-primary transition-colors hover:text-primary" href="#">The Latency Bottleneck</a>
|
|
||||||
<a class="text-gray-500 hover:text-gray-900 pl-2 border-l-2 border-transparent transition-colors" href="#">Implementing Edge Caching</a>
|
|
||||||
<a class="text-gray-500 hover:text-gray-900 pl-2 border-l-2 border-transparent transition-colors" href="#">The Results</a>
|
|
||||||
<a class="text-gray-500 hover:text-gray-900 pl-2 border-l-2 border-transparent transition-colors" href="#">Predictive Pre-Generation</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
|
|
||||||
Related Docs
|
|
||||||
</h4>
|
|
||||||
<div class="space-y-3 text-sm">
|
|
||||||
<a class="flex items-center gap-2 text-gray-600 hover:text-primary transition-colors group" href="#">
|
|
||||||
<span class="material-icons text-[18px] text-gray-400 group-hover:text-primary">description</span>
|
|
||||||
API Caching Policy
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-2 text-gray-600 hover:text-primary transition-colors group" href="#">
|
|
||||||
<span class="material-icons text-[18px] text-gray-400 group-hover:text-primary">terminal</span>
|
|
||||||
CLI Reference
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-2 text-gray-600 hover:text-primary transition-colors group" href="#">
|
|
||||||
<span class="material-icons text-[18px] text-gray-400 group-hover:text-primary">webhook</span>
|
|
||||||
Webhooks Guide
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl border border-gray-700 bg-slate-800 p-6 shadow-xl relative overflow-hidden group">
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"></div>
|
|
||||||
<div class="relative z-10">
|
|
||||||
<h4 class="font-bold text-white text-lg mb-2">Build faster with Banatie</h4>
|
|
||||||
<p class="text-sm text-gray-400 mb-6 leading-relaxed">
|
|
||||||
Integrate AI image generation into your app in minutes. Start for free.
|
|
||||||
</p>
|
|
||||||
<a class="block w-full rounded-lg bg-primary px-4 py-2.5 text-center text-sm font-semibold text-white shadow-lg hover:bg-violet-600 transition-all transform hover:-translate-y-0.5" href="#">
|
|
||||||
Get API Key
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
|
|
||||||
Related Articles
|
|
||||||
</h4>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<a class="group block rounded-xl border border-gray-200 overflow-hidden bg-white hover:border-primary/50 transition-colors shadow-sm" href="#">
|
|
||||||
<div class="aspect-video w-full bg-gray-100 relative overflow-hidden">
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-pink-500/10 to-primary/10 group-hover:scale-105 transition-transform duration-500"></div>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center text-primary/40">
|
|
||||||
<span class="material-icons text-4xl">auto_graph</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<h5 class="text-base font-semibold text-gray-900 group-hover:text-primary transition-colors leading-tight mb-2">
|
|
||||||
Understanding Diffusion Models
|
|
||||||
</h5>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Oct 12 • 5 min read
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a class="group block rounded-xl border border-gray-200 overflow-hidden bg-white hover:border-primary/50 transition-colors shadow-sm" href="#">
|
|
||||||
<div class="aspect-video w-full bg-gray-100 relative overflow-hidden">
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-cyan-500/10 group-hover:scale-105 transition-transform duration-500"></div>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center text-blue-500/40">
|
|
||||||
<span class="material-icons text-4xl">speed</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<h5 class="text-base font-semibold text-gray-900 group-hover:text-primary transition-colors leading-tight mb-2">
|
|
||||||
Managing API Quotas effectively
|
|
||||||
</h5>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Sep 28 • 4 min read
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer class="bg-background-dark text-gray-400 border-t border-white/10">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-8">
|
|
||||||
<div class="col-span-2 lg:col-span-2">
|
|
||||||
<div class="flex items-center gap-2 font-bold text-white text-xl tracking-tight mb-4">
|
|
||||||
<div class="h-6 w-6 rounded bg-gradient-to-br from-primary to-pink-500 flex items-center justify-center">
|
|
||||||
<span class="material-icons text-white text-xs">auto_awesome</span>
|
|
||||||
</div>
|
|
||||||
<span>Banatie</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm leading-6 mb-6 max-w-sm">
|
|
||||||
Empowering developers to build the next generation of creative applications with production-ready AI infrastructure.
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<a class="hover:text-white transition-colors" href="#"><span class="sr-only">Twitter</span><svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"></path></svg></a>
|
|
||||||
<a class="hover:text-white transition-colors" href="#"><span class="sr-only">GitHub</span><svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path clip-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" fill-rule="evenodd"></path></svg></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-semibold text-white mb-4">Product</h3>
|
|
||||||
<ul class="space-y-3 text-sm">
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">Features</a></li>
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">Pricing</a></li>
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">API Reference</a></li>
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">Integrations</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-semibold text-white mb-4">Resources</h3>
|
|
||||||
<ul class="space-y-3 text-sm">
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">Documentation</a></li>
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">Guides</a></li>
|
|
||||||
<li><a class="text-white font-medium" href="#">Blog</a></li>
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">Community</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-semibold text-white mb-4">Company</h3>
|
|
||||||
<ul class="space-y-3 text-sm">
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">About</a></li>
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">Careers</a></li>
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">Legal</a></li>
|
|
||||||
<li><a class="hover:text-white transition-colors" href="#">Contact</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-12 pt-8 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-4 text-xs">
|
|
||||||
<p>© 2023 Banatie Inc. All rights reserved.</p>
|
|
||||||
<div class="flex gap-6">
|
|
||||||
<a class="hover:text-white" href="#">Privacy Policy</a>
|
|
||||||
<a class="hover:text-white" href="#">Terms of Service</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</body></html>
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import {
|
|
||||||
getAllPosts,
|
|
||||||
getPostBySlug,
|
|
||||||
getPostsBySlugs,
|
|
||||||
generatePostMetadata,
|
|
||||||
} from '../utils';
|
|
||||||
import {
|
|
||||||
BlogPostHeader,
|
|
||||||
BlogTOC,
|
|
||||||
BlogSidebar,
|
|
||||||
BlogShareButtons,
|
|
||||||
} from '../_components';
|
|
||||||
import type { BlogPost } from '../types';
|
|
||||||
|
|
||||||
const generateJsonLd = (post: BlogPost) => ({
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'Article',
|
|
||||||
headline: post.title,
|
|
||||||
description: post.description,
|
|
||||||
image: `https://banatie.app${post.heroImage}`,
|
|
||||||
datePublished: post.date,
|
|
||||||
dateModified: post.date,
|
|
||||||
author: {
|
|
||||||
'@type': 'Person',
|
|
||||||
name: post.author.name,
|
|
||||||
},
|
|
||||||
publisher: {
|
|
||||||
'@type': 'Organization',
|
|
||||||
name: 'Banatie',
|
|
||||||
logo: {
|
|
||||||
'@type': 'ImageObject',
|
|
||||||
url: 'https://banatie.app/banatie-logo.png',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mainEntityOfPage: {
|
|
||||||
'@type': 'WebPage',
|
|
||||||
'@id': `https://banatie.app/blog/${post.slug}/`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
|
||||||
const posts = getAllPosts();
|
|
||||||
return posts.map((post) => ({ slug: post.slug }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
||||||
const { slug } = await params;
|
|
||||||
const post = getPostBySlug(slug);
|
|
||||||
if (!post) return {};
|
|
||||||
return generatePostMetadata(post);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogPostPage({ params }: PageProps) {
|
|
||||||
const { slug } = await params;
|
|
||||||
const post = getPostBySlug(slug);
|
|
||||||
|
|
||||||
if (!post) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { Content, tocItems } = await import(`../_posts/${slug}`);
|
|
||||||
const relatedArticles = getPostsBySlugs(post.relatedArticles);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<script
|
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(generateJsonLd(post)) }}
|
|
||||||
/>
|
|
||||||
<main id="main-content">
|
|
||||||
<BlogPostHeader post={post} />
|
|
||||||
|
|
||||||
<div className="bg-white border-t-0 -mt-1 pt-12 lg:pt-16 pb-12">
|
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="lg:grid lg:grid-cols-12 lg:gap-12">
|
|
||||||
{/* Share buttons column - hidden on mobile */}
|
|
||||||
<aside className="hidden lg:block lg:col-span-1">
|
|
||||||
<BlogShareButtons url={`/blog/${post.slug}`} title={post.title} />
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Article content */}
|
|
||||||
<div className="lg:col-span-8">
|
|
||||||
<article className="max-w-none text-gray-700 leading-relaxed [&>p]:mb-4 [&>ul]:mb-4 [&>ul]:list-disc [&>ul]:pl-6 [&>ul_li]:mb-2 [&>ul]:marker:text-violet-500 [&>ol]:mb-4 [&>ol]:list-decimal [&>ol]:pl-6 [&>a]:text-violet-500 [&>a]:hover:underline [&_strong]:text-gray-900 [&_strong]:font-semibold">
|
|
||||||
<Content />
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar - hidden on mobile */}
|
|
||||||
<aside className="hidden lg:block lg:col-span-3">
|
|
||||||
<div className="sticky top-28 space-y-8">
|
|
||||||
<BlogTOC items={tocItems} />
|
|
||||||
<BlogSidebar
|
|
||||||
relatedArticles={relatedArticles}
|
|
||||||
relatedDocs={post.relatedDocs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import type { BlogPost } from '../types';
|
|
||||||
|
|
||||||
interface BlogArticleCardProps {
|
|
||||||
post: BlogPost;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryColors: Record<string, string> = {
|
|
||||||
guides: 'bg-violet-500/10',
|
|
||||||
tutorials: 'bg-blue-500/10',
|
|
||||||
'use-cases': 'bg-pink-500/10',
|
|
||||||
news: 'bg-emerald-500/10',
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatShortDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BlogArticleCard = ({ post }: BlogArticleCardProps) => {
|
|
||||||
const overlayColor = categoryColors[post.category] || 'bg-violet-500/10';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
className="group flex flex-col bg-[#111827] rounded-xl overflow-hidden border border-white/5 hover:border-violet-500/50 transition-all hover:shadow-lg hover:shadow-violet-500/5 h-full"
|
|
||||||
>
|
|
||||||
<div className="aspect-video w-full relative overflow-hidden bg-gray-900">
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 ${overlayColor} group-hover:bg-transparent transition-colors z-10`}
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
src={post.heroImage}
|
|
||||||
alt={post.title}
|
|
||||||
fill
|
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105 opacity-80 group-hover:opacity-100"
|
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-3 left-3 z-20">
|
|
||||||
<span className="inline-flex items-center rounded-md bg-black/60 backdrop-blur-md px-2.5 py-1 text-xs font-medium text-white ring-1 ring-inset ring-white/10 capitalize">
|
|
||||||
{post.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-5 flex flex-col flex-1">
|
|
||||||
<h3 className="text-lg font-bold text-white mb-3 line-clamp-2 group-hover:text-violet-400 transition-colors leading-snug">
|
|
||||||
{post.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
|
|
||||||
{post.description}
|
|
||||||
</p>
|
|
||||||
<div className="mt-auto flex items-center text-xs text-gray-500 font-medium">
|
|
||||||
<span>{formatShortDate(post.date)}</span>
|
|
||||||
<span className="mx-2">•</span>
|
|
||||||
<span>{post.readTime}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
export const BlogBackground = () => {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-0 pointer-events-none overflow-hidden">
|
|
||||||
<div className="absolute -top-[20%] -right-[10%] w-[800px] h-[800px] bg-violet-500/5 rounded-full blur-[120px]" />
|
|
||||||
<div className="absolute top-[10%] left-0 w-[500px] h-[500px] bg-blue-600/5 rounded-full blur-[100px]" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
|
||||||
label: string;
|
|
||||||
href?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlogBreadcrumbsProps {
|
|
||||||
items: BreadcrumbItem[];
|
|
||||||
variant?: 'light' | 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogBreadcrumbs = ({
|
|
||||||
items,
|
|
||||||
variant = 'dark',
|
|
||||||
}: BlogBreadcrumbsProps) => {
|
|
||||||
const textColor = variant === 'dark' ? 'text-gray-400' : 'text-gray-600';
|
|
||||||
const hoverColor =
|
|
||||||
variant === 'dark' ? 'hover:text-white' : 'hover:text-gray-900';
|
|
||||||
const activeColor = variant === 'dark' ? 'text-white' : 'text-gray-900';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="flex items-center gap-2 text-sm" aria-label="Breadcrumb">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isLast = index === items.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.label} className="flex items-center gap-2">
|
|
||||||
{index > 0 && (
|
|
||||||
<ChevronRight className={`w-4 h-4 ${textColor}`} />
|
|
||||||
)}
|
|
||||||
{item.href && !isLast ? (
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
className={`${textColor} ${hoverColor} transition-colors`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={isLast ? activeColor : textColor}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface BlogCTAProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
buttonText: string;
|
|
||||||
buttonHref: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogCTA = ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
buttonText,
|
|
||||||
buttonHref,
|
|
||||||
}: BlogCTAProps) => {
|
|
||||||
return (
|
|
||||||
<div className="my-8 p-6 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-lg border border-purple-100">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
|
||||||
<p className="text-gray-600 mb-4">{description}</p>
|
|
||||||
<Link
|
|
||||||
href={buttonHref}
|
|
||||||
className="inline-block px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
||||||
>
|
|
||||||
{buttonText}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import type { BlogPost } from '../types';
|
|
||||||
import { formatDate } from '../utils';
|
|
||||||
|
|
||||||
interface BlogCardProps {
|
|
||||||
post: BlogPost;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogCard = ({ post }: BlogCardProps) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
className="block border border-white/10 rounded-lg overflow-hidden hover:border-purple-500/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="aspect-video relative bg-slate-800">
|
|
||||||
<Image
|
|
||||||
src={post.heroImage}
|
|
||||||
alt={post.title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="text-xs text-purple-400 mb-2">{post.category}</div>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">{post.title}</h3>
|
|
||||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
|
|
||||||
{post.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Image
|
|
||||||
src={post.author.avatar}
|
|
||||||
alt={post.author.name}
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="rounded-full"
|
|
||||||
/>
|
|
||||||
<span>{post.author.name}</span>
|
|
||||||
</div>
|
|
||||||
<span>{formatDate(post.date)}</span>
|
|
||||||
<span>{post.readTime}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface BlogCategoriesProps {
|
|
||||||
categories: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryColors: Record<string, string> = {
|
|
||||||
guides: 'bg-violet-500',
|
|
||||||
tutorials: 'bg-blue-500',
|
|
||||||
'use-cases': 'bg-pink-500',
|
|
||||||
news: 'bg-emerald-500',
|
|
||||||
engineering: 'bg-blue-500',
|
|
||||||
product: 'bg-purple-500',
|
|
||||||
design: 'bg-green-500',
|
|
||||||
culture: 'bg-orange-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BlogCategories = ({ categories }: BlogCategoriesProps) => {
|
|
||||||
const entries = Object.entries(categories);
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl bg-[#111827] border border-white/5 p-6">
|
|
||||||
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4 border-b border-white/5 pb-2">
|
|
||||||
Categories
|
|
||||||
</h4>
|
|
||||||
<nav className="flex flex-col space-y-1">
|
|
||||||
{entries.map(([category, count]) => {
|
|
||||||
const dotColor = categoryColors[category] || 'bg-gray-500';
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={category}
|
|
||||||
href={`/blog?category=${category}`}
|
|
||||||
className="flex items-center justify-between px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-white/5 hover:text-white transition-colors group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
|
|
||||||
<span className="capitalize">{category}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs bg-white/5 px-2 py-0.5 rounded text-gray-500 group-hover:text-gray-300 transition-colors">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface BlogCodeBlockProps {
|
|
||||||
children: string;
|
|
||||||
language?: string;
|
|
||||||
filename?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogCodeBlock = ({
|
|
||||||
children,
|
|
||||||
language = 'text',
|
|
||||||
filename,
|
|
||||||
}: BlogCodeBlockProps) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(children);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayName = filename || language;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-8 overflow-hidden rounded-xl border border-gray-200 bg-[#1e1e1e] shadow-xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-white/5 bg-[#252526] px-4 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
|
||||||
<div className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
|
|
||||||
<div className="h-3 w-3 rounded-full bg-[#27c93f]" />
|
|
||||||
</div>
|
|
||||||
{displayName && (
|
|
||||||
<span className="ml-4 text-xs font-mono text-gray-400">
|
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex-grow" />
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="text-xs text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 overflow-x-auto blog-scrollbar">
|
|
||||||
<pre className="font-mono text-sm leading-relaxed text-[#d4d4d4] m-0 p-0 bg-transparent">
|
|
||||||
<code>{children}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
interface BlogHeadingProps {
|
|
||||||
id: string;
|
|
||||||
level: 2 | 3;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogHeading = ({ id, level, children }: BlogHeadingProps) => {
|
|
||||||
const Tag = `h${level}` as const;
|
|
||||||
|
|
||||||
const baseStyles = 'scroll-mt-28 text-gray-900 font-bold tracking-tight';
|
|
||||||
const levelStyles = {
|
|
||||||
2: 'text-2xl mt-12 mb-4 first:mt-0',
|
|
||||||
3: 'text-xl mt-8 mb-3',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tag id={id} className={`${baseStyles} ${levelStyles[level]}`}>
|
|
||||||
{children}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import Image, { StaticImageData } from 'next/image';
|
|
||||||
import { ImageIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface BlogImageProps {
|
|
||||||
src: string | StaticImageData;
|
|
||||||
alt: string;
|
|
||||||
caption?: string;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogImage = ({
|
|
||||||
src,
|
|
||||||
alt,
|
|
||||||
caption,
|
|
||||||
fullWidth = false,
|
|
||||||
}: BlogImageProps) => {
|
|
||||||
const isStaticImage = typeof src !== 'string';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<figure className={`my-8 group ${fullWidth ? '-mx-6' : ''}`}>
|
|
||||||
<div className="overflow-hidden rounded-xl border border-gray-200 shadow-lg transition-all duration-300 hover:shadow-xl">
|
|
||||||
{isStaticImage ? (
|
|
||||||
<Image
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
placeholder="blur"
|
|
||||||
className="w-full h-auto"
|
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 66vw, 800px"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="relative aspect-video bg-gray-100">
|
|
||||||
<Image
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 66vw, 800px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{caption && (
|
|
||||||
<figcaption className="mt-4 flex items-center justify-center gap-2 text-sm text-gray-500">
|
|
||||||
<ImageIcon className="w-4 h-4" />
|
|
||||||
<span>{caption}</span>
|
|
||||||
</figcaption>
|
|
||||||
)}
|
|
||||||
</figure>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { Info, Lightbulb, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
type InfoBoxType = 'info' | 'tip' | 'warning';
|
|
||||||
|
|
||||||
interface BlogInfoBoxProps {
|
|
||||||
type?: InfoBoxType;
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeConfig = {
|
|
||||||
info: {
|
|
||||||
icon: Info,
|
|
||||||
borderColor: 'border-blue-500',
|
|
||||||
bgColor: 'bg-blue-50',
|
|
||||||
iconColor: 'text-blue-600',
|
|
||||||
},
|
|
||||||
tip: {
|
|
||||||
icon: Lightbulb,
|
|
||||||
borderColor: 'border-amber-500',
|
|
||||||
bgColor: 'bg-amber-50',
|
|
||||||
iconColor: 'text-amber-600',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
icon: AlertTriangle,
|
|
||||||
borderColor: 'border-red-500',
|
|
||||||
bgColor: 'bg-red-50',
|
|
||||||
iconColor: 'text-red-600',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BlogInfoBox = ({
|
|
||||||
type = 'info',
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: BlogInfoBoxProps) => {
|
|
||||||
const config = typeConfig[type];
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`my-8 rounded-lg border-l-4 ${config.borderColor} ${config.bgColor} p-6 shadow-sm`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
|
||||||
<Icon className={`w-5 h-5 ${config.iconColor}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-bold text-gray-900 mt-0 mb-2">{title}</h5>
|
|
||||||
<div className="text-sm text-gray-700 leading-relaxed">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
interface BlogLeadParagraphProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogLeadParagraph = ({ children }: BlogLeadParagraphProps) => {
|
|
||||||
return (
|
|
||||||
<p className="lead text-xl text-gray-600 mb-8 font-light leading-relaxed">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { submitEmail } from '@/lib/actions/waitlistActions';
|
|
||||||
|
|
||||||
export const BlogNewsletter = () => {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!email) return;
|
|
||||||
|
|
||||||
setStatus('loading');
|
|
||||||
|
|
||||||
const result = await submitEmail(email);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setStatus('success');
|
|
||||||
setEmail('');
|
|
||||||
} else {
|
|
||||||
setStatus('error');
|
|
||||||
setTimeout(() => setStatus('idle'), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl bg-[#161b28] p-6 text-center border border-white/5 shadow-xl relative overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-violet-500/5 to-transparent pointer-events-none" />
|
|
||||||
<div className="relative z-10">
|
|
||||||
<h4 className="text-base font-bold text-white mb-2">Subscribe to Banatie</h4>
|
|
||||||
<p className="text-sm text-gray-400 mb-4 leading-relaxed">
|
|
||||||
Get the latest articles and updates delivered to your inbox.
|
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="your@email.com"
|
|
||||||
disabled={status === 'loading' || status === 'success'}
|
|
||||||
className="block w-full rounded-lg border border-white/10 bg-[#0B0F19] py-2 px-3 text-white text-sm placeholder:text-gray-600 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 focus:outline-none disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={status === 'loading' || status === 'success'}
|
|
||||||
className="w-full rounded-lg bg-violet-500 px-3 py-2 text-sm font-semibold text-white shadow-md hover:bg-violet-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 transition-all transform hover:-translate-y-0.5 disabled:opacity-50 disabled:hover:translate-y-0"
|
|
||||||
>
|
|
||||||
{status === 'loading' && 'Subscribing...'}
|
|
||||||
{status === 'success' && 'Subscribed!'}
|
|
||||||
{status === 'error' && 'Try again'}
|
|
||||||
{status === 'idle' && 'Subscribe'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
type TabType = 'latest' | 'popular';
|
|
||||||
|
|
||||||
interface BlogPageHeaderProps {
|
|
||||||
title?: string;
|
|
||||||
onTabChange?: (tab: TabType) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogPageHeader = ({
|
|
||||||
title = 'Latest Articles',
|
|
||||||
onTabChange,
|
|
||||||
}: BlogPageHeaderProps) => {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('latest');
|
|
||||||
|
|
||||||
const handleTabClick = (tab: TabType) => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
onTabChange?.(tab);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/5">
|
|
||||||
<h1 className="text-3xl font-bold text-white tracking-tight">{title}</h1>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400 bg-white/5 p-1 rounded-lg border border-white/5">
|
|
||||||
<button
|
|
||||||
onClick={() => handleTabClick('latest')}
|
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
|
||||||
activeTab === 'latest'
|
|
||||||
? 'bg-[#111827] text-white shadow-sm'
|
|
||||||
: 'hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Latest
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleTabClick('popular')}
|
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
|
||||||
activeTab === 'popular'
|
|
||||||
? 'bg-[#111827] text-white shadow-sm'
|
|
||||||
: 'hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Popular
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import Image from 'next/image';
|
|
||||||
import { Clock } from 'lucide-react';
|
|
||||||
import type { BlogPost } from '../types';
|
|
||||||
import { formatDate } from '../utils';
|
|
||||||
import { BlogBreadcrumbs } from './BlogBreadcrumbs';
|
|
||||||
|
|
||||||
interface BlogPostHeaderProps {
|
|
||||||
post: BlogPost;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogPostHeader = ({ post }: BlogPostHeaderProps) => {
|
|
||||||
return (
|
|
||||||
<header className="relative overflow-hidden bg-[#0B0F19] text-white pt-12 pb-16 lg:pt-20 lg:pb-24">
|
|
||||||
{/* Blur blob decorations */}
|
|
||||||
<div className="absolute inset-0 z-0">
|
|
||||||
<div className="absolute -top-24 -left-24 w-96 h-96 bg-violet-500/20 rounded-full blur-3xl" />
|
|
||||||
<div className="absolute top-1/2 right-0 w-[500px] h-[500px] bg-pink-600/10 rounded-full blur-3xl transform translate-x-1/3 -translate-y-1/2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="lg:grid lg:grid-cols-12 lg:gap-16 items-center">
|
|
||||||
{/* Left column - Content */}
|
|
||||||
<div className="lg:col-span-7 mb-12 lg:mb-0">
|
|
||||||
<div className="mb-8">
|
|
||||||
<BlogBreadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: 'Blog', href: '/blog' },
|
|
||||||
{ label: post.category },
|
|
||||||
{ label: post.title },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-8">
|
|
||||||
<span className="inline-flex items-center rounded-full bg-violet-500/20 px-3 py-1 text-xs font-medium text-violet-400 ring-1 ring-inset ring-violet-500/30">
|
|
||||||
{post.category}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-400 text-sm flex items-center gap-1 ml-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
{post.readTime}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold tracking-tight mb-8 leading-tight text-white">
|
|
||||||
{post.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-lg sm:text-xl text-gray-300 mb-10 max-w-2xl leading-relaxed">
|
|
||||||
{post.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 border-t border-white/10 pt-8">
|
|
||||||
<Image
|
|
||||||
src={post.author.avatar}
|
|
||||||
alt={post.author.name}
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
className="rounded-full ring-2 ring-[#0B0F19] object-cover"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-white text-base">
|
|
||||||
{post.author.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
{formatDate(post.date)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column - Hero image */}
|
|
||||||
<div className="lg:col-span-5 relative">
|
|
||||||
<div className="relative rounded-xl overflow-hidden shadow-2xl ring-1 ring-white/10 bg-black/40 backdrop-blur-sm">
|
|
||||||
<Image
|
|
||||||
src={post.heroImage}
|
|
||||||
alt={post.title}
|
|
||||||
width={800}
|
|
||||||
height={600}
|
|
||||||
className="w-full h-auto object-cover aspect-[4/3]"
|
|
||||||
priority
|
|
||||||
fetchPriority="high"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
interface BlogPricingProps {
|
|
||||||
free?: string;
|
|
||||||
paid?: string;
|
|
||||||
perImage?: string;
|
|
||||||
sdk?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogPricing = ({ free, paid, perImage, sdk }: BlogPricingProps) => {
|
|
||||||
return (
|
|
||||||
<div className="my-6 rounded-lg border-l-4 border-emerald-500 bg-gray-50 px-5 py-4">
|
|
||||||
<div className="flex flex-wrap gap-x-8 gap-y-2 text-sm">
|
|
||||||
{free && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Free: </span>
|
|
||||||
<span className="font-semibold text-gray-900">{free}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{paid && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Paid: </span>
|
|
||||||
<span className="font-semibold text-gray-900">{paid}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{perImage && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Per image: </span>
|
|
||||||
<span className="font-bold text-emerald-600 text-base">{perImage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sdk && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">SDK: </span>
|
|
||||||
<span className="font-medium text-gray-700">{sdk}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
interface BlogQuoteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
author?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogQuote = ({ children, author }: BlogQuoteProps) => {
|
|
||||||
return (
|
|
||||||
<blockquote className="my-10 border-l-4 border-violet-500 bg-gray-50 p-6 text-xl italic font-medium leading-relaxed text-gray-800 shadow-sm rounded-r-lg">
|
|
||||||
<p className="m-0">{children}</p>
|
|
||||||
{author && (
|
|
||||||
<footer className="mt-4 text-sm text-gray-500 font-normal not-italic">
|
|
||||||
— {author}
|
|
||||||
</footer>
|
|
||||||
)}
|
|
||||||
</blockquote>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { Search } from 'lucide-react';
|
|
||||||
|
|
||||||
export const BlogSearchInput = () => {
|
|
||||||
return (
|
|
||||||
<div className="relative group">
|
|
||||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
||||||
<Search className="w-4 h-4 text-gray-500 group-focus-within:text-violet-500 transition-colors" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search articles..."
|
|
||||||
className="block w-full rounded-xl border border-white/10 bg-[#161b28] py-3 pl-10 pr-4 text-white placeholder:text-gray-500 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 sm:text-sm shadow-sm transition-all focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { ExternalLink } from 'lucide-react';
|
|
||||||
|
|
||||||
interface BlogServiceLinkProps {
|
|
||||||
href: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogServiceLink = ({ href, children }: BlogServiceLinkProps) => {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 text-violet-600 hover:text-violet-700 font-medium mb-4 group"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
|
|
||||||
<span className="hover:underline">{children}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { Link as LinkIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface BlogShareButtonsProps {
|
|
||||||
url?: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_URL = 'https://banatie.app';
|
|
||||||
|
|
||||||
export const BlogShareButtons = ({ url, title }: BlogShareButtonsProps) => {
|
|
||||||
const shareUrl = url ? `${BASE_URL}${url}` : BASE_URL;
|
|
||||||
const shareTitle = title || '';
|
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy link:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const twitterUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(shareTitle)}`;
|
|
||||||
const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sticky top-38 flex flex-col gap-4 items-center">
|
|
||||||
<a
|
|
||||||
href={twitterUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Share on Twitter"
|
|
||||||
className="p-3 rounded-full bg-white text-gray-500 hover:text-violet-500 transition-colors border border-gray-200 shadow-sm"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
|
||||||
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={linkedinUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Share on LinkedIn"
|
|
||||||
className="p-3 rounded-full bg-white text-gray-500 hover:text-blue-600 transition-colors border border-gray-200 shadow-sm"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
|
||||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCopyLink}
|
|
||||||
aria-label="Copy Link"
|
|
||||||
className="p-3 rounded-full bg-white text-gray-500 hover:text-gray-900 transition-colors border border-gray-200 shadow-sm"
|
|
||||||
>
|
|
||||||
<LinkIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { BookOpen, Code, FileText, Terminal, Webhook } from 'lucide-react';
|
|
||||||
import type { BlogPost, RelatedDoc } from '../types';
|
|
||||||
import { formatDate } from '../utils';
|
|
||||||
|
|
||||||
interface BlogSidebarProps {
|
|
||||||
relatedArticles: BlogPost[];
|
|
||||||
relatedDocs: RelatedDoc[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
||||||
book: BookOpen,
|
|
||||||
code: Code,
|
|
||||||
file: FileText,
|
|
||||||
terminal: Terminal,
|
|
||||||
webhook: Webhook,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BlogSidebar = ({
|
|
||||||
relatedArticles,
|
|
||||||
relatedDocs,
|
|
||||||
}: BlogSidebarProps) => {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{relatedDocs.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
|
|
||||||
Related Docs
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
{relatedDocs.map((doc) => {
|
|
||||||
const Icon = iconMap[doc.icon] || FileText;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={doc.href}
|
|
||||||
href={doc.href}
|
|
||||||
className="flex items-center gap-2 text-gray-600 hover:text-violet-500 transition-colors group"
|
|
||||||
>
|
|
||||||
<Icon className="w-[18px] h-[18px] text-gray-400 group-hover:text-violet-500" />
|
|
||||||
<span>{doc.title}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-700 bg-slate-800 p-6 shadow-xl relative overflow-hidden group">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
|
||||||
<div className="relative z-10">
|
|
||||||
<h4 className="font-bold text-white text-lg mb-2">
|
|
||||||
Build faster with Banatie
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-400 mb-6 leading-relaxed">
|
|
||||||
Integrate AI image generation into your app in minutes. Start for
|
|
||||||
free.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/#get-access"
|
|
||||||
className="block w-full rounded-lg bg-violet-500 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-lg hover:bg-violet-600 transition-all transform hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
Get API Key
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{relatedArticles.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
|
|
||||||
Related Articles
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{relatedArticles.map((article) => (
|
|
||||||
<Link
|
|
||||||
key={article.slug}
|
|
||||||
href={`/blog/${article.slug}`}
|
|
||||||
className="group block rounded-xl border border-gray-200 overflow-hidden bg-white hover:border-violet-500/50 transition-colors shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="aspect-video w-full bg-gray-100 relative overflow-hidden">
|
|
||||||
{article.heroImage ? (
|
|
||||||
<Image
|
|
||||||
src={article.heroImage}
|
|
||||||
alt={article.title}
|
|
||||||
fill
|
|
||||||
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/10 to-pink-500/10 group-hover:scale-105 transition-transform duration-500" />
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-violet-500/40">
|
|
||||||
<FileText className="w-10 h-10" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<h5 className="text-base font-semibold text-gray-900 group-hover:text-violet-500 transition-colors leading-tight mb-2">
|
|
||||||
{article.title}
|
|
||||||
</h5>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{formatDate(article.date)} · {article.readTime}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
import type { TocItem } from '../types';
|
|
||||||
|
|
||||||
interface BlogTOCProps {
|
|
||||||
items: TocItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogTOC = ({ items }: BlogTOCProps) => {
|
|
||||||
const [activeId, setActiveId] = useState<string>('');
|
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setActiveId(entry.target.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ rootMargin: '-20% 0px -35% 0px' }
|
|
||||||
);
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
const element = document.getElementById(item.id);
|
|
||||||
if (element) observer.observe(element);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
const scrollToSection = (id: string) => {
|
|
||||||
const element = document.getElementById(id);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl bg-gray-50 border border-gray-200 p-5 shadow-sm">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="w-full flex items-center justify-between text-xs font-bold text-gray-500 uppercase tracking-wider border-b border-gray-200 pb-2 hover:text-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<span>On This Page</span>
|
|
||||||
<ChevronDown
|
|
||||||
className={`w-4 h-4 transition-transform duration-200 ${
|
|
||||||
isExpanded ? '' : '-rotate-90'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden transition-all duration-300 ${
|
|
||||||
isExpanded ? 'max-h-[2000px] opacity-100 mt-4' : 'max-h-0 opacity-0 mt-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<nav className="flex flex-col space-y-3 text-sm" aria-label="Table of contents">
|
|
||||||
{items.map((item) => {
|
|
||||||
const isActive = activeId === item.id;
|
|
||||||
const isH3 = item.level === 3;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => scrollToSection(item.id)}
|
|
||||||
className={`
|
|
||||||
text-left pl-2 border-l-2 transition-colors
|
|
||||||
${isH3 ? 'ml-3' : ''}
|
|
||||||
${
|
|
||||||
isActive
|
|
||||||
? 'text-gray-900 font-medium border-violet-500'
|
|
||||||
: 'text-gray-500 border-transparent hover:text-gray-900'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface BlogTagsProps {
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogTags = ({ tags }: BlogTagsProps) => {
|
|
||||||
if (tags.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-4">
|
|
||||||
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
|
|
||||||
Popular Tags
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<Link
|
|
||||||
key={tag}
|
|
||||||
href={`/blog?tag=${tag}`}
|
|
||||||
className="px-3 py-1 rounded-full bg-[#111827] border border-white/10 text-xs text-gray-400 hover:text-white hover:border-violet-500/50 transition-colors"
|
|
||||||
>
|
|
||||||
#{tag}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
export { BlogCard } from './BlogCard';
|
|
||||||
export { BlogTOC } from './BlogTOC';
|
|
||||||
export { BlogPostHeader } from './BlogPostHeader';
|
|
||||||
export { BlogBreadcrumbs } from './BlogBreadcrumbs';
|
|
||||||
export { BlogSidebar } from './BlogSidebar';
|
|
||||||
export { BlogHeading } from './BlogHeading';
|
|
||||||
export { BlogImage } from './BlogImage';
|
|
||||||
export { BlogQuote } from './BlogQuote';
|
|
||||||
export { BlogCTA } from './BlogCTA';
|
|
||||||
export { BlogCodeBlock } from './BlogCodeBlock';
|
|
||||||
export { BlogShareButtons } from './BlogShareButtons';
|
|
||||||
export { BlogInfoBox } from './BlogInfoBox';
|
|
||||||
export { BlogLeadParagraph } from './BlogLeadParagraph';
|
|
||||||
export { BlogServiceLink } from './BlogServiceLink';
|
|
||||||
export { BlogPricing } from './BlogPricing';
|
|
||||||
|
|
||||||
export { BlogBackground } from './BlogBackground';
|
|
||||||
export { BlogArticleCard } from './BlogArticleCard';
|
|
||||||
export { BlogPageHeader } from './BlogPageHeader';
|
|
||||||
export { BlogSearchInput } from './BlogSearchInput';
|
|
||||||
export { BlogCategories } from './BlogCategories';
|
|
||||||
export { BlogNewsletter } from './BlogNewsletter';
|
|
||||||
export { BlogTags } from './BlogTags';
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { BlogHeading, BlogQuote, BlogCTA, BlogCodeBlock } from '../_components';
|
|
||||||
import type { TocItem } from '../types';
|
|
||||||
|
|
||||||
export const tocItems: TocItem[] = [
|
|
||||||
{ id: 'overview', text: 'Overview', level: 2 },
|
|
||||||
{ id: 'authentication', text: 'Authentication Setup', level: 2 },
|
|
||||||
{ id: 'error-handling', text: 'Error Handling', level: 2 },
|
|
||||||
{ id: 'rate-limits', text: 'Rate Limits', level: 3 },
|
|
||||||
{ id: 'caching', text: 'Caching Strategies', level: 2 },
|
|
||||||
{ id: 'conclusion', text: 'Conclusion', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Content = () => (
|
|
||||||
<>
|
|
||||||
<BlogHeading id="overview" level={2}>
|
|
||||||
Overview
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Integrating the Banatie API into your application opens up a world of
|
|
||||||
possibilities for dynamic image generation. This guide covers the best
|
|
||||||
practices for a smooth integration.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Whether you are building a web application, mobile app, or backend service,
|
|
||||||
these tips will help you get the most out of the API.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="authentication" level={2}>
|
|
||||||
Authentication Setup
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
All API requests require authentication via an API key. Here is how to
|
|
||||||
set it up properly:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCodeBlock language="javascript">
|
|
||||||
{`const headers = {
|
|
||||||
'X-API-Key': process.env.BANATIE_API_KEY,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch('https://api.banatie.app/text-to-image', {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ prompt: 'modern office' })
|
|
||||||
});`}
|
|
||||||
</BlogCodeBlock>
|
|
||||||
|
|
||||||
<BlogQuote>
|
|
||||||
Never expose your API key in client-side code. Always make API calls
|
|
||||||
from your server.
|
|
||||||
</BlogQuote>
|
|
||||||
|
|
||||||
<BlogHeading id="error-handling" level={2}>
|
|
||||||
Error Handling
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Proper error handling ensures your application gracefully handles
|
|
||||||
failures. The API returns standard HTTP status codes.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCodeBlock language="javascript">
|
|
||||||
{`try {
|
|
||||||
const response = await generateImage(prompt);
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 429) {
|
|
||||||
// Rate limited - implement backoff
|
|
||||||
}
|
|
||||||
throw new Error(\`API error: \${response.status}\`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Image generation failed:', error);
|
|
||||||
// Show fallback image
|
|
||||||
}`}
|
|
||||||
</BlogCodeBlock>
|
|
||||||
|
|
||||||
<BlogHeading id="rate-limits" level={3}>
|
|
||||||
Rate Limits
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
The API has rate limits to ensure fair usage. Plan your requests
|
|
||||||
accordingly and implement exponential backoff for retries.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="caching" level={2}>
|
|
||||||
Caching Strategies
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Caching generated images can significantly reduce API calls and improve
|
|
||||||
response times. Consider caching at multiple levels.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Use CDN caching for frequently requested images and local caching for
|
|
||||||
user-specific content.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="conclusion" level={2}>
|
|
||||||
Conclusion
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Following these best practices will help you build a robust integration
|
|
||||||
with the Banatie API. Start small, test thoroughly, and scale as needed.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCTA
|
|
||||||
title="Ready to integrate?"
|
|
||||||
description="Get your API key and start building today."
|
|
||||||
buttonText="Get API Access"
|
|
||||||
buttonHref="/#get-access"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { BlogHeading, BlogQuote, BlogCTA } from '../_components';
|
|
||||||
import type { TocItem } from '../types';
|
|
||||||
|
|
||||||
export const tocItems: TocItem[] = [
|
|
||||||
{ id: 'introduction', text: 'Introduction', level: 2 },
|
|
||||||
{ id: 'traditional-workflow', text: 'Traditional Workflow Problems', level: 2 },
|
|
||||||
{ id: 'ai-powered-approach', text: 'AI-Powered Approach', level: 2 },
|
|
||||||
{ id: 'figma-integration', text: 'Figma Integration', level: 3 },
|
|
||||||
{ id: 'time-savings', text: 'Time Savings', level: 2 },
|
|
||||||
{ id: 'tips', text: 'Pro Tips', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Content = () => (
|
|
||||||
<>
|
|
||||||
<BlogHeading id="introduction" level={2}>
|
|
||||||
Introduction
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Design workflows have traditionally been bottlenecked by the need for
|
|
||||||
placeholder content. Finding the right stock photos or waiting for
|
|
||||||
actual assets can slow down the creative process significantly.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
AI-generated placeholders offer a new paradigm where designers can
|
|
||||||
instantly visualize their concepts with contextually relevant images.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="traditional-workflow" level={2}>
|
|
||||||
Traditional Workflow Problems
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
The conventional design process often involves searching through stock
|
|
||||||
photo libraries, downloading images, and then realizing they do not quite
|
|
||||||
fit the vision. This cycle repeats multiple times per project.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogQuote author="Design Lead at a Fortune 500">
|
|
||||||
We used to spend hours just finding the right placeholder images.
|
|
||||||
Now we describe what we need and get it instantly.
|
|
||||||
</BlogQuote>
|
|
||||||
|
|
||||||
<BlogHeading id="ai-powered-approach" level={2}>
|
|
||||||
AI-Powered Approach
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
With AI-generated images, you simply describe what you need. Want a
|
|
||||||
hero image showing a team collaboration scene? Just ask for it. Need
|
|
||||||
product mockups with specific aesthetics? Describe the style.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
This approach keeps you in the creative flow instead of breaking
|
|
||||||
concentration to hunt for assets.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="figma-integration" level={3}>
|
|
||||||
Figma Integration
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Many designers are using Banatie URLs directly in Figma prototypes.
|
|
||||||
This means your mockups automatically show relevant images without
|
|
||||||
manual image placement.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="time-savings" level={2}>
|
|
||||||
Time Savings
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Teams report saving 2-4 hours per project on asset hunting alone.
|
|
||||||
That time can now be spent on actual design work and iteration.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
The quick iteration cycle also means stakeholder feedback sessions
|
|
||||||
are more productive since designs look more complete.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="tips" level={2}>
|
|
||||||
Pro Tips
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Start with broad prompts and refine as needed. Use consistent style
|
|
||||||
descriptors across your project for visual coherence. Create a prompt
|
|
||||||
library for commonly used image types in your brand.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCTA
|
|
||||||
title="Transform your workflow"
|
|
||||||
description="Join designers who are already saving hours on every project."
|
|
||||||
buttonText="Start Free Trial"
|
|
||||||
buttonHref="/#get-access"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { BlogHeading, BlogQuote, BlogCTA } from '../_components';
|
|
||||||
import type { TocItem } from '../types';
|
|
||||||
|
|
||||||
export const tocItems: TocItem[] = [
|
|
||||||
{ id: 'introduction', text: 'Introduction', level: 2 },
|
|
||||||
{ id: 'quality-settings', text: 'Quality Settings', level: 2 },
|
|
||||||
{ id: 'resolution', text: 'Resolution Guidelines', level: 3 },
|
|
||||||
{ id: 'consistency', text: 'Maintaining Consistency', level: 2 },
|
|
||||||
{ id: 'performance', text: 'Performance Optimization', level: 2 },
|
|
||||||
{ id: 'dos-and-donts', text: 'Dos and Donts', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Content = () => (
|
|
||||||
<>
|
|
||||||
<BlogHeading id="introduction" level={2}>
|
|
||||||
Introduction
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Generating high-quality AI images consistently requires understanding
|
|
||||||
both the capabilities and limitations of the technology. This guide
|
|
||||||
shares proven practices from thousands of successful generations.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Following these guidelines will help you achieve better results with
|
|
||||||
fewer iterations.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="quality-settings" level={2}>
|
|
||||||
Quality Settings
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
The API offers different quality tiers. Higher quality settings produce
|
|
||||||
better images but take longer to generate. Choose based on your use case.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
For prototyping and development, standard quality is often sufficient.
|
|
||||||
Reserve high quality for production assets.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="resolution" level={3}>
|
|
||||||
Resolution Guidelines
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Match the resolution to your actual display size. Generating 4K images
|
|
||||||
for thumbnail displays wastes resources and slows performance.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogQuote>
|
|
||||||
Start with the exact dimensions you need. You can always regenerate
|
|
||||||
at higher resolution for final production.
|
|
||||||
</BlogQuote>
|
|
||||||
|
|
||||||
<BlogHeading id="consistency" level={2}>
|
|
||||||
Maintaining Consistency
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
For projects requiring visual consistency across multiple images, use
|
|
||||||
consistent style descriptors and seed values. Document your successful
|
|
||||||
prompts for reuse.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Creating a style guide for your project prompts helps team members
|
|
||||||
generate images that feel cohesive.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="performance" level={2}>
|
|
||||||
Performance Optimization
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Cache generated images when possible. Use lazy loading for below-the-fold
|
|
||||||
images. Consider generating images ahead of time for predictable content.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Batch similar requests together when generating multiple images to
|
|
||||||
optimize API usage.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="dos-and-donts" level={2}>
|
|
||||||
Dos and Donts
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
<strong>Do:</strong> Test prompts iteratively, use specific descriptors,
|
|
||||||
cache results, match resolution to need.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
<strong>Do not:</strong> Use vague prompts, generate unnecessarily high
|
|
||||||
resolutions, skip error handling, expose API keys client-side.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCTA
|
|
||||||
title="Put these practices to work"
|
|
||||||
description="Start generating professional-quality images today."
|
|
||||||
buttonText="Get Started"
|
|
||||||
buttonHref="/#get-access"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
Before Width: | Height: | Size: 843 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 721 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 641 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 762 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 610 KiB |
|
Before Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 501 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 551 KiB |
|
|
@ -1,90 +0,0 @@
|
||||||
import {
|
|
||||||
BlogHeading,
|
|
||||||
BlogImage,
|
|
||||||
BlogQuote,
|
|
||||||
BlogCTA,
|
|
||||||
BlogCodeBlock,
|
|
||||||
} from '../_components';
|
|
||||||
import type { TocItem } from '../types';
|
|
||||||
|
|
||||||
export const tocItems: TocItem[] = [
|
|
||||||
{ id: 'introduction', text: 'Introduction', level: 2 },
|
|
||||||
{ id: 'why-ai-placeholders', text: 'Why AI Placeholders?', level: 2 },
|
|
||||||
{ id: 'getting-started', text: 'Getting Started', level: 2 },
|
|
||||||
{ id: 'api-usage', text: 'API Usage', level: 3 },
|
|
||||||
{ id: 'next-steps', text: 'Next Steps', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Content = () => (
|
|
||||||
<>
|
|
||||||
<BlogHeading id="introduction" level={2}>
|
|
||||||
Introduction
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Placeholder images have been a staple of web development for decades.
|
|
||||||
From simple gray boxes to stock photos, developers have always needed
|
|
||||||
a way to visualize layouts before final content is ready.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Banatie takes this concept to the next level with AI-generated
|
|
||||||
contextual placeholders that actually match your design intent.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="why-ai-placeholders" level={2}>
|
|
||||||
Why AI Placeholders?
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Traditional placeholder services give you random images that rarely
|
|
||||||
match your actual content needs. With AI-powered placeholders, you get
|
|
||||||
images that are contextually relevant to your project.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogQuote author="A Developer">
|
|
||||||
Finally, placeholder images that actually look like what the final
|
|
||||||
product will have. No more explaining to clients why there are cats
|
|
||||||
everywhere.
|
|
||||||
</BlogQuote>
|
|
||||||
|
|
||||||
<BlogHeading id="getting-started" level={2}>
|
|
||||||
Getting Started
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Getting started with Banatie is simple. You can use our CDN URLs
|
|
||||||
directly in your HTML or integrate with our API for more control.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="api-usage" level={3}>
|
|
||||||
API Usage
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Here is a simple example of how to use Banatie in your HTML:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCodeBlock language="html">
|
|
||||||
{`<img
|
|
||||||
src="https://cdn.banatie.app/demo/live/hero?prompt=modern+office+workspace"
|
|
||||||
alt="Office workspace"
|
|
||||||
/>`}
|
|
||||||
</BlogCodeBlock>
|
|
||||||
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
The prompt parameter tells our AI what kind of image to generate.
|
|
||||||
Be descriptive for best results!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="next-steps" level={2}>
|
|
||||||
Next Steps
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Ready to start using AI-powered placeholders in your projects?
|
|
||||||
Check out our documentation for more detailed examples and API reference.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCTA
|
|
||||||
title="Ready to get started?"
|
|
||||||
description="Join our early access program and start generating contextual placeholder images today."
|
|
||||||
buttonText="Get Early Access"
|
|
||||||
buttonHref="/#get-access"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { BlogHeading, BlogQuote, BlogCTA, BlogCodeBlock } from '../_components';
|
|
||||||
import type { TocItem } from '../types';
|
|
||||||
|
|
||||||
export const tocItems: TocItem[] = [
|
|
||||||
{ id: 'what-is-prompt-engineering', text: 'What is Prompt Engineering?', level: 2 },
|
|
||||||
{ id: 'basic-structure', text: 'Basic Prompt Structure', level: 2 },
|
|
||||||
{ id: 'subject', text: 'Subject', level: 3 },
|
|
||||||
{ id: 'style', text: 'Style', level: 3 },
|
|
||||||
{ id: 'context', text: 'Context', level: 3 },
|
|
||||||
{ id: 'common-mistakes', text: 'Common Mistakes', level: 2 },
|
|
||||||
{ id: 'advanced-techniques', text: 'Advanced Techniques', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Content = () => (
|
|
||||||
<>
|
|
||||||
<BlogHeading id="what-is-prompt-engineering" level={2}>
|
|
||||||
What is Prompt Engineering?
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Prompt engineering is the art of crafting text descriptions that guide
|
|
||||||
AI models to generate the exact images you envision. It is a skill that
|
|
||||||
improves with practice and understanding of how AI interprets language.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Good prompts are specific, descriptive, and structured in a way that
|
|
||||||
the AI can parse effectively.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="basic-structure" level={2}>
|
|
||||||
Basic Prompt Structure
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
A well-structured prompt typically includes three key elements: subject,
|
|
||||||
style, and context. Let us break down each component.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="subject" level={3}>
|
|
||||||
Subject
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
The subject is what you want to see in the image. Be specific about
|
|
||||||
the main focus.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCodeBlock language="text">
|
|
||||||
{`Bad: "a person"
|
|
||||||
Good: "a young professional woman working at a laptop"
|
|
||||||
Better: "a young professional woman in business casual attire working at a MacBook in a modern office"`}
|
|
||||||
</BlogCodeBlock>
|
|
||||||
|
|
||||||
<BlogHeading id="style" level={3}>
|
|
||||||
Style
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Style descriptors help define the visual aesthetic of the generated
|
|
||||||
image.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCodeBlock language="text">
|
|
||||||
{`Style keywords:
|
|
||||||
- "photorealistic" - for lifelike images
|
|
||||||
- "minimalist" - clean, simple compositions
|
|
||||||
- "vibrant colors" - saturated, energetic palette
|
|
||||||
- "soft lighting" - gentle, diffused light`}
|
|
||||||
</BlogCodeBlock>
|
|
||||||
|
|
||||||
<BlogHeading id="context" level={3}>
|
|
||||||
Context
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Context provides background information and setting details.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogQuote>
|
|
||||||
The more context you provide, the more aligned the result will be
|
|
||||||
with your vision. But avoid being so specific that you constrain
|
|
||||||
the AI too much.
|
|
||||||
</BlogQuote>
|
|
||||||
|
|
||||||
<BlogHeading id="common-mistakes" level={2}>
|
|
||||||
Common Mistakes
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Avoid vague prompts like "something nice" or overly complex prompts
|
|
||||||
with contradicting elements. Do not list too many subjects as this
|
|
||||||
confuses the generation.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Another common mistake is not iterating. Your first prompt rarely
|
|
||||||
produces the perfect result - refine and adjust.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="advanced-techniques" level={2}>
|
|
||||||
Advanced Techniques
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
As you gain experience, try techniques like negative prompting
|
|
||||||
(specifying what you do not want), weighted terms, and compositional
|
|
||||||
guidance.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Keep a prompt journal to track what works for your specific use cases.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCTA
|
|
||||||
title="Practice makes perfect"
|
|
||||||
description="Start experimenting with prompts and see immediate results."
|
|
||||||
buttonText="Try It Now"
|
|
||||||
buttonHref="/#get-access"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import { BlogHeading, BlogQuote, BlogCTA, BlogCodeBlock } from '../_components';
|
|
||||||
import type { TocItem } from '../types';
|
|
||||||
|
|
||||||
export const tocItems: TocItem[] = [
|
|
||||||
{ id: 'overview', text: 'Overview', level: 2 },
|
|
||||||
{ id: 'product-mockups', text: 'Product Mockups', level: 2 },
|
|
||||||
{ id: 'category-banners', text: 'Category Banners', level: 2 },
|
|
||||||
{ id: 'lifestyle-images', text: 'Lifestyle Images', level: 3 },
|
|
||||||
{ id: 'ab-testing', text: 'A/B Testing Visuals', level: 2 },
|
|
||||||
{ id: 'implementation', text: 'Implementation Example', level: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Content = () => (
|
|
||||||
<>
|
|
||||||
<BlogHeading id="overview" level={2}>
|
|
||||||
Overview
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
E-commerce platforms face unique challenges when it comes to visual
|
|
||||||
content. From product listings to marketing banners, the need for
|
|
||||||
high-quality images is constant and demanding.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
AI-generated placeholders offer e-commerce teams a way to prototype,
|
|
||||||
test, and iterate faster than ever before.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="product-mockups" level={2}>
|
|
||||||
Product Mockups
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Before product photography is complete, use AI to generate realistic
|
|
||||||
product mockups. This allows the development and marketing teams to
|
|
||||||
work in parallel rather than waiting for final assets.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogQuote author="E-commerce Director">
|
|
||||||
We launched our new collection page two weeks earlier by using AI
|
|
||||||
placeholders during development. Real photos were swapped in seamlessly
|
|
||||||
when ready.
|
|
||||||
</BlogQuote>
|
|
||||||
|
|
||||||
<BlogHeading id="category-banners" level={2}>
|
|
||||||
Category Banners
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Category and promotional banners can be prototyped instantly. Test
|
|
||||||
different visual themes before committing to professional photo shoots.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
This is especially valuable for seasonal campaigns where timing is
|
|
||||||
critical.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="lifestyle-images" level={3}>
|
|
||||||
Lifestyle Images
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Lifestyle images showing products in use are expensive to produce.
|
|
||||||
AI can generate concept images to validate ideas before investing
|
|
||||||
in professional shoots.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="ab-testing" level={2}>
|
|
||||||
A/B Testing Visuals
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Test different visual styles with real users before full production.
|
|
||||||
Generate multiple variations of hero images and measure which performs
|
|
||||||
better.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
This data-driven approach to visual design can significantly improve
|
|
||||||
conversion rates.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogHeading id="implementation" level={2}>
|
|
||||||
Implementation Example
|
|
||||||
</BlogHeading>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-4">
|
|
||||||
Here is a simple example of using Banatie for product category images:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<BlogCodeBlock language="html">
|
|
||||||
{`<!-- Category Banner -->
|
|
||||||
<div class="category-banner">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/demo/live/banner?prompt=summer+fashion+collection+minimal+style"
|
|
||||||
alt="Summer Collection"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Product Grid Placeholder -->
|
|
||||||
<div class="product-image">
|
|
||||||
<img
|
|
||||||
src="https://cdn.banatie.app/demo/live/product?prompt=white+sneakers+product+photo"
|
|
||||||
alt="Product"
|
|
||||||
/>
|
|
||||||
</div>`}
|
|
||||||
</BlogCodeBlock>
|
|
||||||
|
|
||||||
<BlogCTA
|
|
||||||
title="Accelerate your e-commerce development"
|
|
||||||
description="Join online stores already using AI placeholders to ship faster."
|
|
||||||
buttonText="Get Early Access"
|
|
||||||
buttonHref="/#get-access"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import type { BlogPost } from './types';
|
|
||||||
|
|
||||||
export const blogPosts: BlogPost[] = [
|
|
||||||
{
|
|
||||||
slug: 'midjourney-alternatives',
|
|
||||||
title: 'Best Midjourney Alternatives in 2026',
|
|
||||||
description:
|
|
||||||
'19 AI image generators compared: UI platforms, API services, open-source options, and aggregators. Find the right tool for your needs.',
|
|
||||||
heroImage: '/blog/midjourney-alternatives-hero.png',
|
|
||||||
category: 'guides',
|
|
||||||
date: '2026-01-18',
|
|
||||||
author: {
|
|
||||||
name: 'Banatie Team',
|
|
||||||
avatar: '/blog/authors/banatie-team.png',
|
|
||||||
},
|
|
||||||
readTime: '25 min',
|
|
||||||
relatedArticles: ['api-integration-tips', 'placeholder-images-guide'],
|
|
||||||
relatedDocs: [
|
|
||||||
{ title: 'API Reference', href: '/docs/api/', icon: 'code' },
|
|
||||||
{ title: 'MCP Integration', href: '/docs/guides/', icon: 'terminal' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// slug: 'placeholder-images-guide',
|
|
||||||
// title: 'Getting Started with AI Placeholder Images',
|
|
||||||
// description:
|
|
||||||
// 'Learn how to use Banatie to generate contextual placeholder images for your projects.',
|
|
||||||
// heroImage: '/blog/placeholder-guide-hero.jpg',
|
|
||||||
// category: 'guides',
|
|
||||||
// date: '2025-01-15',
|
|
||||||
// author: {
|
|
||||||
// name: 'Banatie Team',
|
|
||||||
// avatar: '/blog/authors/default.jpg',
|
|
||||||
// },
|
|
||||||
// readTime: '5 min',
|
|
||||||
// relatedArticles: ['api-integration-tips', 'prompt-engineering-basics'],
|
|
||||||
// relatedDocs: [
|
|
||||||
// { title: 'API Reference', href: '/docs/api/', icon: 'code' },
|
|
||||||
// { title: 'Quick Start', href: '/docs/guides/', icon: 'book' },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// slug: 'api-integration-tips',
|
|
||||||
// title: 'API Integration Tips for Developers',
|
|
||||||
// description:
|
|
||||||
// 'Best practices for integrating Banatie API into your applications and workflows.',
|
|
||||||
// heroImage: '/blog/api-tips-hero.jpg',
|
|
||||||
// category: 'tutorials',
|
|
||||||
// date: '2025-01-12',
|
|
||||||
// author: {
|
|
||||||
// name: 'Banatie Team',
|
|
||||||
// avatar: '/blog/authors/default.jpg',
|
|
||||||
// },
|
|
||||||
// readTime: '7 min',
|
|
||||||
// relatedArticles: ['placeholder-images-guide', 'image-generation-best-practices'],
|
|
||||||
// relatedDocs: [
|
|
||||||
// { title: 'API Reference', href: '/docs/api/', icon: 'code' },
|
|
||||||
// { title: 'Authentication', href: '/docs/guides/', icon: 'book' },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// slug: 'design-workflow-optimization',
|
|
||||||
// title: 'Optimizing Your Design Workflow with AI Images',
|
|
||||||
// description:
|
|
||||||
// 'How designers can speed up their prototyping process using AI-generated placeholders.',
|
|
||||||
// heroImage: '/blog/design-workflow-hero.jpg',
|
|
||||||
// category: 'guides',
|
|
||||||
// date: '2025-01-10',
|
|
||||||
// author: {
|
|
||||||
// name: 'Banatie Team',
|
|
||||||
// avatar: '/blog/authors/default.jpg',
|
|
||||||
// },
|
|
||||||
// readTime: '6 min',
|
|
||||||
// relatedArticles: ['use-cases-ecommerce', 'placeholder-images-guide'],
|
|
||||||
// relatedDocs: [
|
|
||||||
// { title: 'Getting Started', href: '/docs/guides/', icon: 'book' },
|
|
||||||
// { title: 'Image Parameters', href: '/docs/api/', icon: 'code' },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// slug: 'prompt-engineering-basics',
|
|
||||||
// title: 'Prompt Engineering for Better Image Results',
|
|
||||||
// description:
|
|
||||||
// 'Master the art of writing prompts that generate exactly the images you need.',
|
|
||||||
// heroImage: '/blog/prompt-engineering-hero.jpg',
|
|
||||||
// category: 'tutorials',
|
|
||||||
// date: '2025-01-08',
|
|
||||||
// author: {
|
|
||||||
// name: 'Banatie Team',
|
|
||||||
// avatar: '/blog/authors/default.jpg',
|
|
||||||
// },
|
|
||||||
// readTime: '8 min',
|
|
||||||
// relatedArticles: ['image-generation-best-practices', 'placeholder-images-guide'],
|
|
||||||
// relatedDocs: [
|
|
||||||
// { title: 'Prompt Guide', href: '/docs/guides/', icon: 'book' },
|
|
||||||
// { title: 'API Reference', href: '/docs/api/', icon: 'code' },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// slug: 'image-generation-best-practices',
|
|
||||||
// title: 'Best Practices for AI Image Generation',
|
|
||||||
// description:
|
|
||||||
// "Learn the do's and don'ts of generating high-quality AI images for your projects.",
|
|
||||||
// heroImage: '/blog/best-practices-hero.jpg',
|
|
||||||
// category: 'guides',
|
|
||||||
// date: '2025-01-05',
|
|
||||||
// author: {
|
|
||||||
// name: 'Banatie Team',
|
|
||||||
// avatar: '/blog/authors/default.jpg',
|
|
||||||
// },
|
|
||||||
// readTime: '6 min',
|
|
||||||
// relatedArticles: ['prompt-engineering-basics', 'api-integration-tips'],
|
|
||||||
// relatedDocs: [
|
|
||||||
// { title: 'Image Quality', href: '/docs/guides/', icon: 'book' },
|
|
||||||
// { title: 'Parameters', href: '/docs/api/', icon: 'code' },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// slug: 'use-cases-ecommerce',
|
|
||||||
// title: 'AI Placeholders for E-commerce Projects',
|
|
||||||
// description:
|
|
||||||
// 'How online stores can use AI-generated images for product mockups and prototypes.',
|
|
||||||
// heroImage: '/blog/ecommerce-hero.jpg',
|
|
||||||
// category: 'use-cases',
|
|
||||||
// date: '2025-01-02',
|
|
||||||
// author: {
|
|
||||||
// name: 'Banatie Team',
|
|
||||||
// avatar: '/blog/authors/default.jpg',
|
|
||||||
// },
|
|
||||||
// readTime: '5 min',
|
|
||||||
// relatedArticles: ['design-workflow-optimization', 'image-generation-best-practices'],
|
|
||||||
// relatedDocs: [
|
|
||||||
// { title: 'Quick Start', href: '/docs/guides/', icon: 'book' },
|
|
||||||
// { title: 'Batch Generation', href: '/docs/api/', icon: 'code' },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
];
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { getAllPosts } from './utils';
|
|
||||||
import {
|
|
||||||
BlogBackground,
|
|
||||||
BlogArticleCard,
|
|
||||||
BlogPageHeader,
|
|
||||||
// BlogSearchInput,
|
|
||||||
// BlogCategories,
|
|
||||||
BlogNewsletter,
|
|
||||||
// BlogTags,
|
|
||||||
} from './_components';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Blog | Banatie',
|
|
||||||
description:
|
|
||||||
'Articles, guides, and updates about AI-powered image generation.',
|
|
||||||
robots: 'index, follow',
|
|
||||||
|
|
||||||
alternates: {
|
|
||||||
canonical: '/blog/',
|
|
||||||
languages: {
|
|
||||||
en: '/blog/',
|
|
||||||
'x-default': '/blog/',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
openGraph: {
|
|
||||||
type: 'website',
|
|
||||||
url: '/blog/',
|
|
||||||
title: 'Blog | Banatie',
|
|
||||||
description:
|
|
||||||
'Articles, guides, and updates about AI-powered image generation.',
|
|
||||||
siteName: 'Banatie',
|
|
||||||
locale: 'en_US',
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: '/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'Banatie Blog - AI Image Generation Articles',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
|
||||||
site: '@BanatieApp',
|
|
||||||
creator: '@BanatieApp',
|
|
||||||
title: 'Blog | Banatie',
|
|
||||||
description:
|
|
||||||
'Articles, guides, and updates about AI-powered image generation.',
|
|
||||||
images: ['/og-image.png'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// const defaultTags = ['ai', 'image-generation', 'api', 'midjourney', 'flux'];
|
|
||||||
|
|
||||||
export default function BlogPage() {
|
|
||||||
const posts = getAllPosts();
|
|
||||||
// const categories = getCategories();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BlogBackground />
|
|
||||||
<main id="main-content" className="flex-grow bg-transparent relative z-10 pt-10 pb-12 lg:pt-16 lg:pb-20">
|
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12">
|
|
||||||
<div className="lg:col-span-8 xl:col-span-9">
|
|
||||||
<BlogPageHeader />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
||||||
{posts.map((post) => (
|
|
||||||
<BlogArticleCard key={post.slug} post={post} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{posts.length === 0 && (
|
|
||||||
<p className="text-gray-500 text-center py-12">
|
|
||||||
No articles yet. Check back soon!
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{posts.length > 6 && (
|
|
||||||
<div className="mt-16 flex justify-center">
|
|
||||||
<button className="px-6 py-3 rounded-lg border border-white/10 bg-[#111827] text-sm font-medium text-white hover:bg-white/5 transition-colors">
|
|
||||||
Load More Articles
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="lg:col-span-4 xl:col-span-3 space-y-8">
|
|
||||||
{/* <BlogSearchInput /> */}
|
|
||||||
{/* <BlogCategories categories={categories} /> */}
|
|
||||||
<BlogNewsletter />
|
|
||||||
{/* <BlogTags tags={defaultTags} /> */}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
export interface BlogAuthor {
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RelatedDoc {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlogPost {
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
heroImage: string;
|
|
||||||
category: string;
|
|
||||||
date: string;
|
|
||||||
author: BlogAuthor;
|
|
||||||
readTime: string;
|
|
||||||
relatedArticles: string[];
|
|
||||||
relatedDocs: RelatedDoc[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TocItem {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
level: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import type { BlogPost } from './types';
|
|
||||||
import { blogPosts } from './blog-posts';
|
|
||||||
|
|
||||||
export const getAllPosts = (): BlogPost[] => blogPosts;
|
|
||||||
|
|
||||||
export const getPostBySlug = (slug: string): BlogPost | undefined =>
|
|
||||||
blogPosts.find((p) => p.slug === slug);
|
|
||||||
|
|
||||||
export const getPostsBySlugs = (slugs: string[]): BlogPost[] =>
|
|
||||||
slugs
|
|
||||||
.map((slug) => getPostBySlug(slug))
|
|
||||||
.filter((post): post is BlogPost => post !== undefined);
|
|
||||||
|
|
||||||
export const generatePostMetadata = (post: BlogPost): Metadata => ({
|
|
||||||
title: `${post.title} | Banatie Blog`,
|
|
||||||
description: post.description,
|
|
||||||
robots: 'index, follow',
|
|
||||||
|
|
||||||
alternates: {
|
|
||||||
canonical: `/blog/${post.slug}/`,
|
|
||||||
languages: {
|
|
||||||
en: `/blog/${post.slug}/`,
|
|
||||||
'x-default': `/blog/${post.slug}/`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
openGraph: {
|
|
||||||
type: 'article',
|
|
||||||
url: `/blog/${post.slug}/`,
|
|
||||||
title: post.title,
|
|
||||||
description: post.description,
|
|
||||||
siteName: 'Banatie',
|
|
||||||
locale: 'en_US',
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: post.heroImage,
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: post.title,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
publishedTime: post.date,
|
|
||||||
authors: [post.author.name],
|
|
||||||
},
|
|
||||||
|
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
|
||||||
site: '@BanatieApp',
|
|
||||||
creator: '@BanatieApp',
|
|
||||||
title: post.title,
|
|
||||||
description: post.description,
|
|
||||||
images: [post.heroImage],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const formatDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCategories = (): Record<string, number> => {
|
|
||||||
return blogPosts.reduce(
|
|
||||||
(acc, post) => {
|
|
||||||
acc[post.category] = (acc[post.category] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -8,16 +8,8 @@ export default function LandingsLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Skip to content link for accessibility */}
|
|
||||||
<a
|
|
||||||
href="#main-content"
|
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-violet-600 focus:text-white focus:rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-400"
|
|
||||||
>
|
|
||||||
Skip to content
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Sticky Header */}
|
{/* Sticky Header */}
|
||||||
<header className="sticky top-0 z-50 bg-slate-900/80 backdrop-blur-md border-b border-white/5">
|
<header className="sticky top-0 z-10 bg-slate-900/80 backdrop-blur-md border-b border-white/5">
|
||||||
<nav className="max-w-7xl mx-auto px-4 sm:px-6 py-2 sm:py-3 flex justify-between items-center h-12 sm:h-14 md:h-16">
|
<nav className="max-w-7xl mx-auto px-4 sm:px-6 py-2 sm:py-3 flex justify-between items-center h-12 sm:h-14 md:h-16">
|
||||||
<a href="/" className="h-full flex items-center">
|
<a href="/" className="h-full flex items-center">
|
||||||
<Image
|
<Image
|
||||||
|
|
|
||||||
|
|
@ -148,23 +148,3 @@ pre::-webkit-scrollbar-thumb {
|
||||||
pre::-webkit-scrollbar-thumb:hover {
|
pre::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgb(148, 163, 184); /* slate-400 */
|
background: rgb(148, 163, 184); /* slate-400 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blog code block scrollbar - light track for dark code blocks */
|
|
||||||
.blog-scrollbar::-webkit-scrollbar {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: #1f2937;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: #4b5563;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
import { blogPosts } from './(landings)/blog/blog-posts';
|
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const baseUrl = 'https://banatie.app';
|
const baseUrl = 'https://banatie.app';
|
||||||
|
|
@ -11,19 +10,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
changeFrequency: 'weekly',
|
changeFrequency: 'weekly',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
},
|
},
|
||||||
// Blog
|
|
||||||
{
|
|
||||||
url: `${baseUrl}/blog/`,
|
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: 'weekly',
|
|
||||||
priority: 0.9,
|
|
||||||
},
|
|
||||||
...blogPosts.map((post) => ({
|
|
||||||
url: `${baseUrl}/blog/${post.slug}/`,
|
|
||||||
lastModified: new Date(post.date),
|
|
||||||
changeFrequency: 'monthly' as const,
|
|
||||||
priority: 0.8,
|
|
||||||
})),
|
|
||||||
// Documentation - Guides
|
// Documentation - Guides
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/docs/`,
|
url: `${baseUrl}/docs/`,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
export const footerLinks = [
|
export const footerLinks = [
|
||||||
{ label: 'Blog', href: '/blog/' },
|
|
||||||
{ label: 'Documentation', href: '/docs/' },
|
{ label: 'Documentation', href: '/docs/' },
|
||||||
{ label: 'API Reference', href: '/docs/api/' },
|
{ label: 'API Reference', href: '/docs/api/' },
|
||||||
// { label: 'Pricing', href: '#' },
|
// { label: 'Pricing', href: '#' },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
# @base = http://localhost:3000
|
@base = http://localhost:3000
|
||||||
@base = http://api.banatie.app
|
@apiKey = bnt_71e7e16732ac5e21f597edc56e99e8c3696e713552ec9d1f44dfeffb2ef7c495
|
||||||
@apiKey = bnt_991200385acbfb47f2c159ac53bed67afc3ecc29597771b1d74b89c5e4c39c4b
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# IMAGE REFERENCES & ALIASES TESTING
|
# IMAGE REFERENCES & ALIASES TESTING
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# Banatie Lab
|
|
||||||
|
|
||||||
Banatie Lab is the browser UI for working with the Banatie API: generating images, browsing the gallery of created images, managing prompts, aliases, and flows. It is referenced on the landing page as "Web Lab".
|
|
||||||
|
|
||||||
The Lab will be built **from scratch**. This folder collects everything that was previously designed or documented, so the ideas are not lost.
|
|
||||||
|
|
||||||
## Documents in this folder
|
|
||||||
|
|
||||||
| Document | Origin | Purpose |
|
|
||||||
|----------|--------|---------|
|
|
||||||
| [design-system.md](design-system.md) | `feature/lab-design` / `feature/lab-frontend` branches (`apps/landing/src/app/(lab)/CLAUDE.md`) | Full design system and layout architecture for the Lab UI |
|
|
||||||
| [legacy-workbench.md](legacy-workbench.md) | `apps/landing/WORKBENCH_DOCUMENTATION.md` (main) | Text-to-Image workbench — predecessor of the Generate page (MinimizedApiKey badge, PromptReuseButton) |
|
|
||||||
| [legacy-expanded-image-view.md](legacy-expanded-image-view.md) | `apps/landing/src/components/shared/EXPANDED_IMAGE_VIEW.md` (main) | Full-size image modal with metadata — building block for the gallery |
|
|
||||||
|
|
||||||
## Planned feature set (from design-system.md)
|
|
||||||
|
|
||||||
Pages that were planned in the abandoned `feature/lab-*` branches:
|
|
||||||
|
|
||||||
- `/lab/generate` — generation workbench (prompt, references, parameters)
|
|
||||||
- `/lab/images` — gallery of generated images with filters
|
|
||||||
- `/lab/live` — live URL / prompt-URL testing
|
|
||||||
- `/lab/upload` — file upload interface
|
|
||||||
|
|
||||||
Additional concepts mentioned in the design doc: alias management and flow control.
|
|
||||||
|
|
||||||
## API capabilities the UI must expose
|
|
||||||
|
|
||||||
The functional scope is defined by the existing API documentation:
|
|
||||||
|
|
||||||
- [../api/image-generation.md](../api/image-generation.md) — basic generation
|
|
||||||
- [../api/image-generation-advanced.md](../api/image-generation-advanced.md) — reference images, aliases (`@hero`), flows, flow-scoped aliases (`@best`), dynamic aliases (`@last`, `@first`, `@upload`), regeneration
|
|
||||||
- [../api/live-url.md](../api/live-url.md) — CDN architecture, live URLs (`/live/{scope}?prompt=...`), prompt-based caching
|
|
||||||
- [../api/images-upload.md](../api/images-upload.md) — uploads
|
|
||||||
- [../api/admin.md](../api/admin.md) — API key management
|
|
||||||
|
|
||||||
## Design direction (summary)
|
|
||||||
|
|
||||||
- Work-focused tool, not marketing — compact typography, tight spacing, like Google AI Studio
|
|
||||||
- Dual color system: zinc for layout chrome, slate for forms/cards; purple→cyan accent gradient
|
|
||||||
- Lucide React icons only, no emojis
|
|
||||||
- Scroll-aware collapsing header, left filter sidebar, contextual footer
|
|
||||||
- Responsive 768px–1920px+
|
|
||||||
|
|
||||||
See [design-system.md](design-system.md) for the complete spec.
|
|
||||||
|
|
||||||
## What does NOT exist
|
|
||||||
|
|
||||||
There is no PRD with a detailed feature list (gallery filters, prompt list, flow list behavior). The pages above plus the API docs are the only recorded ideas. A proper PRD should be written here before implementation starts.
|
|
||||||
|
|
@ -1,364 +0,0 @@
|
||||||
# Lab Section Design System
|
|
||||||
|
|
||||||
The Lab section is a production-ready UI interface for interacting with the Banatie API service. It provides a clean, work-focused experience for image generation, gallery browsing, alias management, and flow control.
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/landing/src/
|
|
||||||
├── app/(lab)/
|
|
||||||
│ ├── CLAUDE.md # This design documentation
|
|
||||||
│ ├── layout.tsx # Root lab layout with scroll context
|
|
||||||
│ └── lab/
|
|
||||||
│ ├── layout.tsx # Sub-layout with PageProvider
|
|
||||||
│ ├── page.tsx # Redirect to /lab/generate
|
|
||||||
│ ├── generate/page.tsx # Generation workbench
|
|
||||||
│ ├── images/page.tsx # Image gallery browser
|
|
||||||
│ ├── live/page.tsx # Live generation testing
|
|
||||||
│ └── upload/page.tsx # File upload interface
|
|
||||||
│
|
|
||||||
├── components/layout/lab/
|
|
||||||
│ ├── LabLayout.tsx # Main layout (sidebar + content + footer)
|
|
||||||
│ ├── LabSidebar.tsx # Left filter panel
|
|
||||||
│ └── LabFooter.tsx # Contextual footer with links
|
|
||||||
│
|
|
||||||
├── components/lab/
|
|
||||||
│ ├── GenerateFormPlaceholder.tsx # Generation form component
|
|
||||||
│ └── FilterPlaceholder.tsx # Reusable filter checkbox/radio
|
|
||||||
│
|
|
||||||
└── contexts/
|
|
||||||
└── lab-scroll-context.tsx # Scroll state for header collapse
|
|
||||||
```
|
|
||||||
|
|
||||||
## Design Principles
|
|
||||||
|
|
||||||
1. **Work-focused, not marketing** - Small typography, efficient spacing
|
|
||||||
2. **Clean and functional** - Like Google AI Studio
|
|
||||||
3. **Dual color system** - Zinc for layout, Slate for forms
|
|
||||||
4. **Consistent icons** - Lucide React only, no emojis
|
|
||||||
5. **Responsive** - Works on 768px to 1920px+ screens
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Color System
|
|
||||||
|
|
||||||
### Layout/Chrome → Zinc (Neutral Gray)
|
|
||||||
Used for sidebar, footer, and layout borders:
|
|
||||||
```css
|
|
||||||
bg-zinc-950 /* Sidebar background */
|
|
||||||
bg-zinc-950/50 /* Footer background, layout wrappers */
|
|
||||||
border-zinc-800 /* Layout borders, dividers */
|
|
||||||
text-zinc-400 /* Sidebar text */
|
|
||||||
text-zinc-500 /* Footer text, muted */
|
|
||||||
```
|
|
||||||
|
|
||||||
### Forms/Cards → Slate (Original Style)
|
|
||||||
Used for cards, inputs, and interactive UI elements:
|
|
||||||
```css
|
|
||||||
bg-slate-900/80 /* Card backgrounds */
|
|
||||||
bg-slate-900/50 /* Empty states, lighter cards */
|
|
||||||
bg-slate-800 /* Input backgrounds, secondary surfaces */
|
|
||||||
border-slate-700 /* Card borders, input borders */
|
|
||||||
text-gray-400 /* Labels, secondary text */
|
|
||||||
text-gray-500 /* Placeholders, hints */
|
|
||||||
```
|
|
||||||
|
|
||||||
### Accent Colors (Purple/Cyan)
|
|
||||||
```css
|
|
||||||
/* Primary gradient */
|
|
||||||
bg-gradient-to-r from-purple-600 to-cyan-600
|
|
||||||
hover:from-purple-500 hover:to-cyan-500
|
|
||||||
shadow-lg shadow-purple-900/30
|
|
||||||
focus:ring-2 focus:ring-purple-500
|
|
||||||
|
|
||||||
/* Single-color accents */
|
|
||||||
text-purple-400 /* Links, interactive text */
|
|
||||||
|
|
||||||
/* Info banners */
|
|
||||||
bg-purple-900/10 border-purple-700/50
|
|
||||||
```
|
|
||||||
|
|
||||||
### Status Colors
|
|
||||||
```css
|
|
||||||
/* Success */ bg-emerald-500/10 border-emerald-500/30 text-emerald-400
|
|
||||||
/* Warning */ bg-amber-500/10 border-amber-500/30 text-amber-400
|
|
||||||
/* Error */ bg-red-500/10 border-red-500/30 text-red-400
|
|
||||||
/* Info */ bg-purple-900/10 border-purple-700/50 text-purple-400
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Typography Scale
|
|
||||||
|
|
||||||
### Headings (Practical Sizes)
|
|
||||||
```tsx
|
|
||||||
// Page Title (only on main pages)
|
|
||||||
text-lg font-semibold text-white
|
|
||||||
|
|
||||||
// Section Title
|
|
||||||
text-base font-semibold text-white
|
|
||||||
|
|
||||||
// Card Title
|
|
||||||
text-sm font-medium text-white
|
|
||||||
```
|
|
||||||
|
|
||||||
### Body Text
|
|
||||||
```tsx
|
|
||||||
// Primary body
|
|
||||||
text-sm text-gray-300
|
|
||||||
|
|
||||||
// Secondary/descriptions
|
|
||||||
text-sm text-gray-400
|
|
||||||
|
|
||||||
// Small text (hints, metadata)
|
|
||||||
text-xs text-gray-500
|
|
||||||
|
|
||||||
// Labels (form fields)
|
|
||||||
text-xs font-medium text-gray-400
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interactive
|
|
||||||
```tsx
|
|
||||||
// Button text
|
|
||||||
text-sm font-semibold
|
|
||||||
|
|
||||||
// Links
|
|
||||||
text-sm text-purple-400 hover:text-purple-300
|
|
||||||
|
|
||||||
// Badge/count
|
|
||||||
text-xs text-gray-600
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Spacing System
|
|
||||||
|
|
||||||
### Padding
|
|
||||||
```tsx
|
|
||||||
// Cards
|
|
||||||
p-4 md:p-5
|
|
||||||
|
|
||||||
// Compact cards
|
|
||||||
p-3
|
|
||||||
|
|
||||||
// Form inputs
|
|
||||||
px-3 py-2
|
|
||||||
|
|
||||||
// Buttons (primary)
|
|
||||||
px-4 py-2.5
|
|
||||||
|
|
||||||
// Buttons (secondary)
|
|
||||||
px-3 py-2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Section Spacing
|
|
||||||
```tsx
|
|
||||||
// Page padding
|
|
||||||
p-4 md:p-6
|
|
||||||
|
|
||||||
// Between sections
|
|
||||||
space-y-4
|
|
||||||
|
|
||||||
// Between cards in grid
|
|
||||||
gap-3 md:gap-4
|
|
||||||
|
|
||||||
// Between form fields
|
|
||||||
gap-2
|
|
||||||
|
|
||||||
// Label to input
|
|
||||||
mb-1.5
|
|
||||||
```
|
|
||||||
|
|
||||||
### Border Radius
|
|
||||||
```tsx
|
|
||||||
rounded-lg /* Standard (inputs, small cards) */
|
|
||||||
rounded-xl /* Medium (cards) */
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Patterns
|
|
||||||
|
|
||||||
### Page Header
|
|
||||||
```tsx
|
|
||||||
<header className="pb-3 border-b border-zinc-800">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Sparkles className="w-5 h-5 text-purple-400" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-semibold text-white">Generate</h1>
|
|
||||||
<p className="text-xs text-gray-400">Create AI images from text prompts</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Form Card (Slate)
|
|
||||||
```tsx
|
|
||||||
<section className="p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl">
|
|
||||||
{/* content */}
|
|
||||||
</section>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Form Input (Slate)
|
|
||||||
```tsx
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs font-medium text-gray-400">Field Label</label>
|
|
||||||
<input
|
|
||||||
className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
placeholder="Enter value..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Textarea (Slate)
|
|
||||||
```tsx
|
|
||||||
<textarea
|
|
||||||
className="w-full px-3 py-2.5 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Primary Button
|
|
||||||
```tsx
|
|
||||||
<button className="px-4 py-2 text-sm rounded-lg bg-gradient-to-r from-purple-600 to-cyan-600 text-white font-semibold hover:from-purple-500 hover:to-cyan-500 transition-all shadow-lg shadow-purple-900/30 focus:ring-2 focus:ring-purple-500 flex items-center gap-1.5">
|
|
||||||
<Sparkles className="w-4 h-4" />
|
|
||||||
Generate
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Secondary Button (Slate)
|
|
||||||
```tsx
|
|
||||||
<button className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-gray-400 hover:text-white hover:bg-slate-700 transition-colors flex items-center justify-center gap-1.5 focus:ring-2 focus:ring-purple-500">
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
Configure
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Empty State (Slate)
|
|
||||||
```tsx
|
|
||||||
<div className="p-6 bg-slate-900/50 backdrop-blur-sm border border-slate-700 rounded-lg text-center">
|
|
||||||
<div className="w-12 h-12 mx-auto mb-3 flex items-center justify-center bg-slate-800 rounded-lg">
|
|
||||||
<ImageOff className="w-6 h-6 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm font-medium text-white mb-1">No results yet</h3>
|
|
||||||
<p className="text-xs text-gray-400">Generated images will appear here</p>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Info Banner
|
|
||||||
```tsx
|
|
||||||
<div className="p-3 bg-purple-900/10 border border-purple-700/50 rounded-lg">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Info className="w-4 h-4 text-purple-400 mt-0.5 shrink-0" />
|
|
||||||
<p className="text-xs text-gray-300">
|
|
||||||
<span className="font-medium text-white">Lab Mode:</span> Experimental features enabled.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Icon System (Lucide React)
|
|
||||||
|
|
||||||
### Standard Sizes
|
|
||||||
```tsx
|
|
||||||
// Inline with text
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
|
|
||||||
// Small (badges)
|
|
||||||
<Icon className="w-3 h-3" />
|
|
||||||
|
|
||||||
// Medium (empty states)
|
|
||||||
<Icon className="w-6 h-6" />
|
|
||||||
|
|
||||||
// With margin (before text)
|
|
||||||
<Icon className="w-4 h-4 mr-1.5" />
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recommended Icons
|
|
||||||
```tsx
|
|
||||||
// Navigation
|
|
||||||
import { Home, Image, Upload, Zap, Settings } from 'lucide-react';
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
import { Plus, X, Download, Share2, Copy, Trash2, Edit3, MoreVertical } from 'lucide-react';
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
import { Activity, Calendar, Palette, ChevronRight, ChevronDown } from 'lucide-react';
|
|
||||||
|
|
||||||
// Status
|
|
||||||
import { CheckCircle2, AlertCircle, XCircle, Info, Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
// Media
|
|
||||||
import { ImageOff, FileImage, Sparkles } from 'lucide-react';
|
|
||||||
|
|
||||||
// Form
|
|
||||||
import { Search, Filter, SlidersHorizontal } from 'lucide-react';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Responsive Breakpoints
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Base: < 768px (mobile) - single column, no sidebar
|
|
||||||
// md: >= 768px (tablet) - 2 columns, no sidebar
|
|
||||||
// lg: >= 1024px (desktop) - sidebar visible, 2-3 columns
|
|
||||||
// xl: >= 1280px (large desktop) - optimal spacing, 3 columns
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sidebar Behavior
|
|
||||||
```tsx
|
|
||||||
// Hidden on mobile/tablet, visible lg+
|
|
||||||
hidden lg:block w-64
|
|
||||||
```
|
|
||||||
|
|
||||||
### Content Grid
|
|
||||||
```tsx
|
|
||||||
// Images: 1 col mobile, 2 col tablet, 3 col desktop
|
|
||||||
grid-cols-1 md:grid-cols-2 xl:grid-cols-3
|
|
||||||
|
|
||||||
// Form fields
|
|
||||||
grid-cols-1 md:grid-cols-3
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Do's and Don'ts
|
|
||||||
|
|
||||||
### DO
|
|
||||||
- Use **zinc** for layout (sidebar, footer, layout borders)
|
|
||||||
- Use **slate** for forms (cards, inputs, empty states)
|
|
||||||
- Use text-sm/text-xs for most text
|
|
||||||
- Use Lucide icons exclusively
|
|
||||||
- Keep spacing tight (p-3 to p-5)
|
|
||||||
- Add focus:ring-2 focus:ring-purple-500 to all interactive elements
|
|
||||||
- Use transitions (transition-colors, transition-all)
|
|
||||||
|
|
||||||
### DON'T
|
|
||||||
- Use emojis anywhere in the UI
|
|
||||||
- Use marketing-size headings (text-3xl+)
|
|
||||||
- Use generous spacing (p-8+, py-12+)
|
|
||||||
- Mix zinc and slate inconsistently
|
|
||||||
- Forget aria-label on icon-only buttons
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Layout Architecture
|
|
||||||
|
|
||||||
The Lab uses a scroll-aware header system:
|
|
||||||
|
|
||||||
1. **LabScrollProvider** wraps the entire section
|
|
||||||
2. **LabLayout** detects scroll > 50px in content area
|
|
||||||
3. When scrolled, header collapses (h-16 → h-0)
|
|
||||||
4. SubsectionNav becomes the top element
|
|
||||||
5. Content height adjusts: `h-[calc(100vh-7rem)]` → `h-[calc(100vh-3rem)]`
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Layout hierarchy
|
|
||||||
(lab)/layout.tsx → LabScrollProvider + LabHeader
|
|
||||||
└── lab/layout.tsx → PageProvider (SubsectionNav)
|
|
||||||
└── LabLayout.tsx → ThreeColumnLayout (sidebar + content + footer)
|
|
||||||
└── page.tsx → Actual page content
|
|
||||||
```
|
|
||||||
10
package.json
|
|
@ -18,9 +18,6 @@
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:api": "tsx tests/api/run-all.ts",
|
"test:api": "tsx tests/api/run-all.ts",
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"test:e2e:headed": "playwright test --headed",
|
|
||||||
"test:e2e:report": "playwright show-report test-results/html-report",
|
|
||||||
"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",
|
||||||
|
|
@ -48,15 +45,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.60.0",
|
|
||||||
"@types/node": "^20.11.0",
|
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^3.2.4",
|
||||||
"eslint-config-prettier": "^9.1.2",
|
"eslint-config-prettier": "^9.1.2",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"kill-port": "^2.0.1",
|
"kill-port": "^2.0.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tsx": "^4.7.0",
|
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"@types/node": "^20.11.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './tests/e2e',
|
|
||||||
outputDir: './test-results/artifacts',
|
|
||||||
fullyParallel: true,
|
|
||||||
retries: 0,
|
|
||||||
reporter: [['list'], ['html', { outputFolder: 'test-results/html-report', open: 'never' }]],
|
|
||||||
use: {
|
|
||||||
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3010',
|
|
||||||
screenshot: 'only-on-failure',
|
|
||||||
trace: 'retain-on-failure',
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
webServer: {
|
|
||||||
command: 'pnpm --filter @banatie/landing dev',
|
|
||||||
url: 'http://localhost:3010',
|
|
||||||
reuseExistingServer: true,
|
|
||||||
timeout: 120_000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -12,9 +12,6 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
|
||||||
specifier: ^1.60.0
|
|
||||||
version: 1.60.0
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.11.0
|
specifier: ^20.11.0
|
||||||
version: 20.19.17
|
version: 20.19.17
|
||||||
|
|
@ -59,7 +56,7 @@ importers:
|
||||||
version: 0.400.0(react@18.3.1)
|
version: 0.400.0(react@18.3.1)
|
||||||
next:
|
next:
|
||||||
specifier: ^14.2.0
|
specifier: ^14.2.0
|
||||||
version: 14.2.33(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.33(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
|
@ -211,7 +208,7 @@ importers:
|
||||||
version: 0.400.0(react@19.1.0)
|
version: 0.400.0(react@19.1.0)
|
||||||
next:
|
next:
|
||||||
specifier: 15.5.9
|
specifier: 15.5.9
|
||||||
version: 15.5.9(@playwright/test@1.60.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 15.5.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
react:
|
react:
|
||||||
specifier: 19.1.0
|
specifier: 19.1.0
|
||||||
version: 19.1.0
|
version: 19.1.0
|
||||||
|
|
@ -260,7 +257,7 @@ importers:
|
||||||
version: 19.1.5(@types/react@19.1.6)
|
version: 19.1.5(@types/react@19.1.6)
|
||||||
next:
|
next:
|
||||||
specifier: ^14.2.0
|
specifier: ^14.2.0
|
||||||
version: 14.2.33(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.33(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
|
@ -1420,11 +1417,6 @@ packages:
|
||||||
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
|
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
|
|
||||||
'@playwright/test@1.60.0':
|
|
||||||
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
|
|
@ -3184,11 +3176,6 @@ packages:
|
||||||
fs.realpath@1.0.0:
|
fs.realpath@1.0.0:
|
||||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||||
|
|
||||||
fsevents@2.3.2:
|
|
||||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
|
@ -3257,17 +3244,15 @@ packages:
|
||||||
glob@10.3.10:
|
glob@10.3.10:
|
||||||
resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==}
|
resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
glob@10.4.5:
|
glob@10.4.5:
|
||||||
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
|
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
|
||||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
glob@7.2.3:
|
glob@7.2.3:
|
||||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
deprecated: Glob versions prior to v9 are no longer supported
|
||||||
|
|
||||||
globals@13.24.0:
|
globals@13.24.0:
|
||||||
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
|
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
|
||||||
|
|
@ -4076,7 +4061,6 @@ packages:
|
||||||
next@14.2.33:
|
next@14.2.33:
|
||||||
resolution: {integrity: sha512-GiKHLsD00t4ACm1p00VgrI0rUFAC9cRDGReKyERlM57aeEZkOQGcZTpIbsGn0b562FTPJWmYfKwplfO9EaT6ng==}
|
resolution: {integrity: sha512-GiKHLsD00t4ACm1p00VgrI0rUFAC9cRDGReKyERlM57aeEZkOQGcZTpIbsGn0b562FTPJWmYfKwplfO9EaT6ng==}
|
||||||
engines: {node: '>=18.17.0'}
|
engines: {node: '>=18.17.0'}
|
||||||
deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.
|
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.1.0
|
'@opentelemetry/api': ^1.1.0
|
||||||
|
|
@ -4294,16 +4278,6 @@ packages:
|
||||||
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
playwright-core@1.60.0:
|
|
||||||
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
playwright@1.60.0:
|
|
||||||
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
plimit-lit@1.6.1:
|
plimit-lit@1.6.1:
|
||||||
resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==}
|
resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -4479,7 +4453,6 @@ packages:
|
||||||
recharts@2.15.4:
|
recharts@2.15.4:
|
||||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
@ -6274,10 +6247,6 @@ snapshots:
|
||||||
|
|
||||||
'@pkgr/core@0.2.9': {}
|
'@pkgr/core@0.2.9': {}
|
||||||
|
|
||||||
'@playwright/test@1.60.0':
|
|
||||||
dependencies:
|
|
||||||
playwright: 1.60.0
|
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|
||||||
'@react-aria/focus@3.21.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@react-aria/focus@3.21.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
|
@ -7844,7 +7813,7 @@ snapshots:
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
||||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||||
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
|
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
|
||||||
|
|
@ -7878,7 +7847,7 @@ snapshots:
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
|
@ -7893,7 +7862,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
|
|
@ -8299,9 +8268,6 @@ snapshots:
|
||||||
|
|
||||||
fs.realpath@1.0.0: {}
|
fs.realpath@1.0.0: {}
|
||||||
|
|
||||||
fsevents@2.3.2:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -9394,7 +9360,7 @@ snapshots:
|
||||||
|
|
||||||
neo-async@2.6.2: {}
|
neo-async@2.6.2: {}
|
||||||
|
|
||||||
next@14.2.33(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
next@14.2.33(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 14.2.33
|
'@next/env': 14.2.33
|
||||||
'@swc/helpers': 0.5.5
|
'@swc/helpers': 0.5.5
|
||||||
|
|
@ -9415,12 +9381,11 @@ snapshots:
|
||||||
'@next/swc-win32-arm64-msvc': 14.2.33
|
'@next/swc-win32-arm64-msvc': 14.2.33
|
||||||
'@next/swc-win32-ia32-msvc': 14.2.33
|
'@next/swc-win32-ia32-msvc': 14.2.33
|
||||||
'@next/swc-win32-x64-msvc': 14.2.33
|
'@next/swc-win32-x64-msvc': 14.2.33
|
||||||
'@playwright/test': 1.60.0
|
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
next@15.5.9(@playwright/test@1.60.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
next@15.5.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 15.5.9
|
'@next/env': 15.5.9
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
|
|
@ -9438,7 +9403,6 @@ snapshots:
|
||||||
'@next/swc-linux-x64-musl': 15.5.7
|
'@next/swc-linux-x64-musl': 15.5.7
|
||||||
'@next/swc-win32-arm64-msvc': 15.5.7
|
'@next/swc-win32-arm64-msvc': 15.5.7
|
||||||
'@next/swc-win32-x64-msvc': 15.5.7
|
'@next/swc-win32-x64-msvc': 15.5.7
|
||||||
'@playwright/test': 1.60.0
|
|
||||||
sharp: 0.34.4
|
sharp: 0.34.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
|
|
@ -9616,14 +9580,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
find-up: 4.1.0
|
find-up: 4.1.0
|
||||||
|
|
||||||
playwright-core@1.60.0: {}
|
|
||||||
|
|
||||||
playwright@1.60.0:
|
|
||||||
dependencies:
|
|
||||||
playwright-core: 1.60.0
|
|
||||||
optionalDependencies:
|
|
||||||
fsevents: 2.3.2
|
|
||||||
|
|
||||||
plimit-lit@1.6.1:
|
plimit-lit@1.6.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-lit: 1.5.2
|
queue-lit: 1.5.2
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
# @base = http://localhost:3000
|
@base = http://localhost:3000
|
||||||
@base = https://api.banatie.app
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
@apiKey = bnt_991200385acbfb47f2c159ac53bed67afc3ecc29597771b1d74b89c5e4c39c4b
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# BASIC GENERATION TESTS
|
# BASIC GENERATION TESTS
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
# @base = http://localhost:3000
|
@base = http://localhost:3000
|
||||||
# @apiKey = bnt_415a15747796a9676cb47c663fcede36af3583cedaec19893b831ec7a2ae0311
|
@apiKey = bnt_727d2f4f72bd03ed96da5278bb971a00cb0a2454d4d70f9748b5c39f3f69d88d
|
||||||
|
|
||||||
@base = https://api.banatie.app
|
|
||||||
@apiKey = bnt_e6eb544c505922b9bfe5b088e067fc3940efff16b1b88585c5518946630d4a66
|
|
||||||
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# IMAGE UPLOAD & CRUD TESTS
|
# IMAGE UPLOAD & CRUD TESTS
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
|
|
||||||
const CONTAINER = process.env.E2E_PG_CONTAINER ?? 'banatie-postgres-dev';
|
|
||||||
const DB_USER = process.env.E2E_PG_USER ?? 'banatie_user';
|
|
||||||
const DB_NAME = process.env.E2E_PG_DB ?? 'banatie_db';
|
|
||||||
|
|
||||||
export const queryDb = <T = Record<string, unknown>>(sql: string): T[] => {
|
|
||||||
const output = execFileSync(
|
|
||||||
'docker',
|
|
||||||
['exec', CONTAINER, 'psql', '-U', DB_USER, '-d', DB_NAME, '--csv', '-c', sql],
|
|
||||||
{ encoding: 'utf-8' },
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
if (!output) return [];
|
|
||||||
|
|
||||||
const [header, ...rows] = output.split('\n');
|
|
||||||
const columns = header.split(',');
|
|
||||||
return rows.map((row) => {
|
|
||||||
const values = row.split(',');
|
|
||||||
return Object.fromEntries(columns.map((col, i) => [col, values[i]])) as T;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { queryDb } from './helpers/db';
|
|
||||||
|
|
||||||
test.describe('Landing page', () => {
|
|
||||||
test('hero section renders with email capture form', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('AI Image Generation');
|
|
||||||
const heroForm = page.locator('#get-access');
|
|
||||||
await expect(heroForm.getByPlaceholder(/email/i)).toBeVisible();
|
|
||||||
await expect(heroForm.getByRole('button', { name: /early access/i })).toBeVisible();
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/screenshots/landing-hero.png', fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('page loads without console errors', async ({ page }) => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Database cross-check', () => {
|
|
||||||
test('dev postgres is reachable and has expected tables', async () => {
|
|
||||||
const tables = queryDb<{ table_name: string }>(
|
|
||||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name",
|
|
||||||
);
|
|
||||||
const names = tables.map((t) => t.table_name);
|
|
||||||
|
|
||||||
expect(names).toContain('api_keys');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -5,7 +5,7 @@ export default defineConfig({
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["**/*.test.ts", "**/*.spec.ts"],
|
include: ["**/*.test.ts", "**/*.spec.ts"],
|
||||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.next/**", "tests/e2e/**"],
|
exclude: ["**/node_modules/**", "**/dist/**", "**/.next/**"],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text", "json", "html"],
|
reporter: ["text", "json", "html"],
|
||||||
|
|
|
||||||