style: auto-format codebase with Prettier

Applied formatting to 1487+ files using pnpm format:write
  - TypeScript/JavaScript files
  - Svelte components
  - Astro pages
  - JSON configs
  - Markdown docs

  13 files still need manual review (Astro JSX comments)
This commit is contained in:
Wuesteon 2025-11-27 18:33:16 +01:00
parent 0241f5554c
commit d36b321d9d
3952 changed files with 661498 additions and 739751 deletions

View file

@ -1,15 +1,11 @@
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase@latest",
"--project-ref=mjuvnnjxwfwlmxjsgkqu"
],
"env": {
"SUPABASE_ACCESS_TOKEN": "sbp_3622a96f728711cd06b113c17f77f84d02ff8fb2"
}
}
}
}
"mcpServers": {
"supabase": {
"command": "npx",
"args": ["-y", "@supabase/mcp-server-supabase@latest", "--project-ref=mjuvnnjxwfwlmxjsgkqu"],
"env": {
"SUPABASE_ACCESS_TOKEN": "sbp_3622a96f728711cd06b113c17f77f84d02ff8fb2"
}
}
}
}

View file

@ -8,6 +8,7 @@
## Problem
The `process-jobs` Edge Function fails with error:
```
{"success":false,"error":"Cannot read properties of undefined (reading 'substring')"}
```
@ -15,14 +16,17 @@ The `process-jobs` Edge Function fails with error:
## Investigation Steps
### Step 1: Test without imports
Created `process-jobs-test` with minimal code (no imports).
**Result:** ✅ Works perfectly
- Environment variables present
- Supabase client initializes
- `claim_next_job()` RPC works
### Step 2: Test WITH process-generation import
Added `import { processGeneration } from '../process-generation/index.ts';`
**Result:** ❌ Same error returns
@ -34,11 +38,12 @@ Added `import { processGeneration } from '../process-generation/index.ts';`
```typescript
// Line 522-565 of process-generation/index.ts
Deno.serve(async (req: Request) => {
// Handler code...
// Handler code...
});
```
**Why this causes the error:**
1. Edge Functions can only have ONE `Deno.serve()` call
2. When `process-jobs` imports `process-generation/index.ts`, it executes the file
3. This tries to call `Deno.serve()` a second time
@ -52,6 +57,7 @@ Deno.serve(async (req: Request) => {
Create a new file `process-generation/lib.ts` that contains ONLY the `processGeneration()` function and helper functions (NO Deno.serve).
**Structure:**
```
supabase/functions/
├── process-generation/
@ -62,6 +68,7 @@ supabase/functions/
```
**Benefits:**
- Clean separation
- Reusable code
- Each function has its own Deno.serve
@ -71,11 +78,13 @@ supabase/functions/
Copy-paste the `processGeneration()` function directly into `process-jobs/index.ts`.
**Benefits:**
- Simple
- No import issues
- All code in one place
**Drawbacks:**
- Code duplication
- Harder to maintain
- Larger file
@ -85,6 +94,7 @@ Copy-paste the `processGeneration()` function directly into `process-jobs/index.
Remove the Deno.serve handler from `process-generation/index.ts` entirely if it's not needed as a standalone function.
**Drawbacks:**
- Can't call process-generation directly for testing
- Loses standalone functionality
@ -97,6 +107,7 @@ Remove the Deno.serve handler from `process-generation/index.ts` entirely if it'
### Step 1: Create `process-generation/lib.ts`
Extract these from `index.ts`:
- All interfaces (ModelConfig, GenerationParams, GenerationResult)
- All helper functions (gcd, simplifyAspectRatio, convertImageToBase64, buildModelInput, determineOutputFormat)
- Main function: `processGeneration()`
@ -104,13 +115,13 @@ Extract these from `index.ts`:
### Step 2: Update `process-generation/index.ts`
```typescript
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import 'jsr:@supabase/functions-js/edge-runtime.d.ts';
import { processGeneration } from './lib.ts';
Deno.serve(async (req: Request) => {
// Handler code...
const result = await processGeneration(params, replicateApiToken);
// Return response...
// Handler code...
const result = await processGeneration(params, replicateApiToken);
// Return response...
});
```
@ -133,6 +144,7 @@ npx supabase functions deploy process-jobs --project-ref mjuvnnjxwfwlmxjsgkqu
## Testing Plan
1. **Test process-generation standalone:**
```bash
curl -X POST https://.../ /functions/v1/process-generation \
-H 'Authorization: Bearer SERVICE_ROLE_KEY' \
@ -140,12 +152,14 @@ npx supabase functions deploy process-jobs --project-ref mjuvnnjxwfwlmxjsgkqu
```
2. **Test process-jobs:**
```bash
curl -X POST https://.../functions/v1/process-jobs \
-H 'Authorization: Bearer SERVICE_ROLE_KEY'
```
3. **Test with real job:**
```sql
SELECT enqueue_job(
'generate-image',
@ -182,12 +196,14 @@ npx supabase functions deploy process-jobs --project-ref mjuvnnjxwfwlmxjsgkqu
## Impact
**Before Fix:**
- ❌ process-jobs fails immediately
- ❌ Cron job fails every minute
- ❌ Jobs stay pending forever
- ✅ Other functions work fine
**After Fix:**
- ✅ process-jobs works
- ✅ Cron job processes queue
- ✅ End-to-end flow complete

View file

@ -8,6 +8,7 @@
## ✅ Successfully Deployed
### 1. Database Migration
- **Status:** ✅ Complete
- **Migration:** `20251009_job_queue_system.sql`
- **Components:**
@ -20,6 +21,7 @@
- ✅ Proper indexes for performance
### 2. Edge Functions
- **start-generation:** ✅ Deployed successfully
- Returns immediately (~100ms)
- Creates generation record and enqueues job
@ -37,6 +39,7 @@
- **Likely Cause:** Import issue with process-generation or Supabase client initialization
### 3. Environment Secrets
- **Status:** ✅ All configured
- **Secrets Set:**
- ✅ `REPLICATE_API_KEY` (already existed)
@ -46,6 +49,7 @@
- ✅ `SUPABASE_DB_URL` (auto-set)
### 4. pg_cron Worker
- **Status:** ✅ Configured and running
- **Schedule:** Every minute (`* * * * *`)
- **Job Name:** `process-job-queue`
@ -60,6 +64,7 @@
### Issue 1: process-jobs Function Runtime Error
**Symptom:**
```bash
curl -X POST https://mjuvnnjxwfwlmxjsgkqu.supabase.co/functions/v1/process-jobs
# Returns: {"success":false,"error":"Cannot read properties of undefined (reading 'substring')"}
@ -67,21 +72,25 @@ curl -X POST https://mjuvnnjxwfwlmxjsgkqu.supabase.co/functions/v1/process-jobs
**Root Cause:**
The error occurs when calling `supabaseAdmin.rpc('claim_next_job')`. This is likely due to:
1. Import of `process-generation/index.ts` causing initialization issues
2. Supabase client not being properly initialized
3. Environment variables not being available
**Impact:**
- The cron job will fail every minute
- Jobs in the queue won't be processed automatically
- Manual triggering via start-generation still works (but jobs stay pending)
**Workaround:**
Until fixed, you can:
1. Use the old `generate-image` function (still deployed)
2. Manually process jobs via SQL: `SELECT * FROM claim_next_job();`
**Next Steps to Fix:**
1. Remove the import of `process-generation` and inline the code
2. Add better error handling and logging
3. Test with a minimal version first
@ -128,17 +137,20 @@ Until fixed, you can:
## 🧪 Testing Status
### Database Functions
- ✅ `enqueue_job()` - Works perfectly
- ✅ `claim_next_job()` - Returns SETOF correctly
- ✅ `complete_job()` - Updates jobs correctly
- ✅ Views (queue_health, failed_jobs_recent, stuck_jobs) - All working
### Edge Functions
- ✅ `start-generation` - Not tested with auth, but deployed
- ✅ `process-generation` - Deployed, used internally
- ⚠️ `process-jobs` - Has runtime error
### pg_cron
- ✅ Extension enabled
- ✅ Cron job scheduled
- ⚠️ Will fail due to process-jobs bug
@ -148,6 +160,7 @@ Until fixed, you can:
## 📝 Quick Commands
### Check Queue Status
```sql
-- Queue health
SELECT * FROM queue_health;
@ -160,6 +173,7 @@ SELECT * FROM failed_jobs_recent;
```
### Manual Job Processing (Workaround)
```sql
-- Claim a job manually
SELECT * FROM claim_next_job();
@ -169,6 +183,7 @@ SELECT complete_job('job-id-here', NULL, NULL);
```
### Check Cron Job Status
```sql
-- Check if cron is running
SELECT * FROM cron.job WHERE jobname = 'process-job-queue';
@ -181,6 +196,7 @@ LIMIT 10;
```
### Edge Function Logs
Dashboard: https://supabase.com/dashboard/project/mjuvnnjxwfwlmxjsgkqu/logs/edge-functions
---
@ -188,6 +204,7 @@ Dashboard: https://supabase.com/dashboard/project/mjuvnnjxwfwlmxjsgkqu/logs/edge
## 🎯 Next Steps
### Immediate (Fix Bug)
1. **Debug process-jobs function**
- Simplify to minimal version
- Remove process-generation import
@ -199,6 +216,7 @@ Dashboard: https://supabase.com/dashboard/project/mjuvnnjxwfwlmxjsgkqu/logs/edge
- Check image is downloaded and stored
### Short-term
1. **Add monitoring dashboard**
- Queue depth alerts
- Failed job notifications
@ -210,6 +228,7 @@ Dashboard: https://supabase.com/dashboard/project/mjuvnnjxwfwlmxjsgkqu/logs/edge
- Implement rate limiting
### Long-term
1. **Add more job types**
- Batch generation
- Image variations

View file

@ -10,6 +10,7 @@
### Option A: Via Supabase Dashboard (EMPFOHLEN für Production)
1. **Öffne Supabase Dashboard:**
```
https://supabase.com/dashboard/project/mjuvnnjxwfwlmxjsgkqu
```
@ -25,6 +26,7 @@
- Warte auf Success Message
5. **Verifiziere:**
```sql
-- Check tables
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = 'job_queue';
@ -61,6 +63,7 @@ npx supabase functions deploy start-generation --project-ref mjuvnnjxwfwlmxjsgkq
```
**Expected Output:**
```
✓ Deployed Function start-generation
```
@ -90,6 +93,7 @@ npx supabase secrets list --project-ref mjuvnnjxwfwlmxjsgkqu
```
**Secrets needed:**
- `REPLICATE_API_TOKEN` - Your Replicate API key
- `SUPABASE_URL` - Auto-set
- `SUPABASE_ANON_KEY` - Auto-set
@ -102,6 +106,7 @@ npx supabase secrets list --project-ref mjuvnnjxwfwlmxjsgkqu
### 4.1 Enable pg_cron Extension
**Via SQL Editor:**
```sql
-- Enable extension
CREATE EXTENSION IF NOT EXISTS pg_cron;
@ -180,6 +185,7 @@ SELECT complete_job('job-id-here', NULL, NULL);
### 5.2 Test Edge Functions
#### Test start-generation:
```bash
curl -X POST \
https://mjuvnnjxwfwlmxjsgkqu.supabase.co/functions/v1/start-generation \
@ -192,16 +198,18 @@ curl -X POST \
```
**Expected Response:**
```json
{
"success": true,
"generation_id": "uuid-here",
"job_id": "uuid-here",
"status": "queued"
"success": true,
"generation_id": "uuid-here",
"job_id": "uuid-here",
"status": "queued"
}
```
#### Test process-jobs (manual trigger):
```bash
curl -X POST \
https://mjuvnnjxwfwlmxjsgkqu.supabase.co/functions/v1/process-jobs \
@ -281,6 +289,7 @@ SELECT * FROM stuck_jobs;
### Issue: Jobs stuck in pending
**Check:**
```sql
-- Is cron running?
SELECT * FROM cron.job_run_details ORDER BY start_time DESC LIMIT 5;
@ -290,6 +299,7 @@ SELECT * FROM job_queue WHERE status = 'processing';
```
**Fix:**
- Manually trigger: `curl ... /process-jobs`
- Check service role key is correct
- Check Edge Function logs
@ -297,11 +307,13 @@ SELECT * FROM job_queue WHERE status = 'processing';
### Issue: Jobs failing
**Check:**
```sql
SELECT error_message FROM failed_jobs_recent;
```
**Common Causes:**
- Missing REPLICATE_API_TOKEN
- Invalid model_id
- Replicate API down
@ -311,11 +323,13 @@ SELECT error_message FROM failed_jobs_recent;
## 📝 Notes
**Project:**
- ID: mjuvnnjxwfwlmxjsgkqu
- URL: https://mjuvnnjxwfwlmxjsgkqu.supabase.co
- Region: EU Central
**Important URLs:**
- Dashboard: https://supabase.com/dashboard/project/mjuvnnjxwfwlmxjsgkqu
- SQL Editor: https://supabase.com/dashboard/project/mjuvnnjxwfwlmxjsgkqu/sql
- Functions: https://supabase.com/dashboard/project/mjuvnnjxwfwlmxjsgkqu/functions

View file

@ -55,12 +55,14 @@ pnpm dev:landing
### Mobile App (`apps/mobile`)
**Tech Stack:**
- React Native 0.81 + Expo SDK 54
- Expo Router (File-based Routing)
- NativeWind (Tailwind für React Native)
- Zustand (State Management)
**Features:**
- ✅ Native iOS & Android Experience
- ✅ Bildgenerierung mit AI Models
- ✅ Gallery mit Infinite Scroll
@ -69,6 +71,7 @@ pnpm dev:landing
- ✅ Offline-fähig
**Starten:**
```bash
pnpm dev:mobile
```
@ -76,11 +79,13 @@ pnpm dev:mobile
### Web App (`apps/web`)
**Tech Stack:**
- SvelteKit 2.x + Svelte 5
- Tailwind CSS
- Server-Side Rendering (SSR)
**Features:**
- ✅ Volle Web-Anwendung
- ✅ Responsive Design
- ✅ SEO-optimiert durch SSR
@ -88,6 +93,7 @@ pnpm dev:mobile
- ✅ Masonry Gallery Layout
**Starten:**
```bash
pnpm dev:web
```
@ -95,17 +101,20 @@ pnpm dev:web
### Landing Page (`apps/landing`)
**Tech Stack:**
- Astro 5.x
- Tailwind CSS
- Static Site Generation
**Features:**
- ✅ Ultraschnell (0 JS by default)
- ✅ SEO-optimiert
- ✅ Marketing-optimiert
- ✅ Statically Generated
**Starten:**
```bash
pnpm dev:landing
```
@ -115,6 +124,7 @@ pnpm dev:landing
### `@picture/shared`
Geteilte Business Logic zwischen allen Apps:
- Supabase Types & API Clients
- Image Utilities
- Validation Logic
@ -124,6 +134,7 @@ Geteilte Business Logic zwischen allen Apps:
### `@picture/memoro-ui`
Shared UI Component Library mit CLI Tool:
- Wiederverwendbare UI Components
- Cross-platform (React Native & Web)
- CLI für Component Management
@ -166,6 +177,7 @@ pnpm clean # Alle Build-Artefakte & node_modules löschen
## 🗄️ Datenbank
Das Projekt verwendet **Supabase** für:
- PostgreSQL Database
- Authentication
- Storage (für Bilder)
@ -256,6 +268,7 @@ Landing:
Dieses Problem tritt auf, wenn React Native Dependencies dupliziert sind (typisch bei PNPM Workspaces).
**Fix:**
```bash
# Alle node_modules und Lock-File löschen
rm -rf node_modules apps/*/node_modules packages/*/node_modules pnpm-lock.yaml

View file

@ -1,3 +1,3 @@
{
"expo": {}
}
"expo": {}
}

View file

@ -4,10 +4,10 @@ import * as dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://picture:password@localhost:5432/picture',
},
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://picture:password@localhost:5432/picture',
},
});

View file

@ -1,8 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -1,58 +1,58 @@
{
"name": "@picture/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-errors": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@supabase/supabase-js": "^2.45.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"multer": "^1.4.5-lts.1",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"replicate": "^0.32.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.11",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
"name": "@picture/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-errors": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@supabase/supabase-js": "^2.45.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"multer": "^1.4.5-lts.1",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"replicate": "^0.32.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.11",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -14,23 +14,23 @@ import { ProfileModule } from './profile/profile.module';
import { BatchModule } from './batch/batch.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
ModelModule,
TagModule,
ImageModule,
BoardModule,
BoardItemModule,
UploadModule,
GenerateModule,
ExploreModule,
ProfileModule,
BatchModule,
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
ModelModule,
TagModule,
ImageModule,
BoardModule,
BoardItemModule,
UploadModule,
GenerateModule,
ExploreModule,
ProfileModule,
BatchModule,
],
})
export class AppModule {}

View file

@ -1,100 +1,67 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Post, Delete, Param, Query, Body, UseGuards } from '@nestjs/common';
import { BatchService } from './batch.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { CreateBatchDto, GetBatchQueryDto } from './dto/batch.dto';
@Controller('batch')
@UseGuards(JwtAuthGuard)
export class BatchController {
constructor(private readonly batchService: BatchService) {}
constructor(private readonly batchService: BatchService) {}
/**
* Create a new batch generation
*/
@Post()
async createBatch(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreateBatchDto,
) {
return this.batchService.createBatch(user.userId, dto);
}
/**
* Create a new batch generation
*/
@Post()
async createBatch(@CurrentUser() user: CurrentUserData, @Body() dto: CreateBatchDto) {
return this.batchService.createBatch(user.userId, dto);
}
/**
* Get all batches for the current user
*/
@Get()
async getUserBatches(
@CurrentUser() user: CurrentUserData,
@Query() query: GetBatchQueryDto,
) {
return this.batchService.getUserBatches(user.userId, query);
}
/**
* Get all batches for the current user
*/
@Get()
async getUserBatches(@CurrentUser() user: CurrentUserData, @Query() query: GetBatchQueryDto) {
return this.batchService.getUserBatches(user.userId, query);
}
/**
* Get a specific batch by ID with its items
*/
@Get(':id')
async getBatch(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.getBatch(batchId, user.userId);
}
/**
* Get a specific batch by ID with its items
*/
@Get(':id')
async getBatch(@CurrentUser() user: CurrentUserData, @Param('id') batchId: string) {
return this.batchService.getBatch(batchId, user.userId);
}
/**
* Get batch progress (for polling)
*/
@Get(':id/progress')
async getBatchProgress(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.getBatchProgress(batchId, user.userId);
}
/**
* Get batch progress (for polling)
*/
@Get(':id/progress')
async getBatchProgress(@CurrentUser() user: CurrentUserData, @Param('id') batchId: string) {
return this.batchService.getBatchProgress(batchId, user.userId);
}
/**
* Retry failed generations in a batch
*/
@Post(':id/retry')
async retryFailed(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.retryFailed(batchId, user.userId);
}
/**
* Retry failed generations in a batch
*/
@Post(':id/retry')
async retryFailed(@CurrentUser() user: CurrentUserData, @Param('id') batchId: string) {
return this.batchService.retryFailed(batchId, user.userId);
}
/**
* Cancel a batch
*/
@Post(':id/cancel')
async cancelBatch(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.cancelBatch(batchId, user.userId);
}
/**
* Cancel a batch
*/
@Post(':id/cancel')
async cancelBatch(@CurrentUser() user: CurrentUserData, @Param('id') batchId: string) {
return this.batchService.cancelBatch(batchId, user.userId);
}
/**
* Delete a batch and all its generations
*/
@Delete(':id')
async deleteBatch(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.deleteBatch(batchId, user.userId);
}
/**
* Delete a batch and all its generations
*/
@Delete(':id')
async deleteBatch(@CurrentUser() user: CurrentUserData, @Param('id') batchId: string) {
return this.batchService.deleteBatch(batchId, user.userId);
}
}

View file

@ -4,9 +4,9 @@ import { BatchService } from './batch.service';
import { DatabaseModule } from '../db/database.module';
@Module({
imports: [DatabaseModule],
controllers: [BatchController],
providers: [BatchService],
exports: [BatchService],
imports: [DatabaseModule],
controllers: [BatchController],
providers: [BatchService],
exports: [BatchService],
})
export class BatchModule {}

View file

@ -1,445 +1,400 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
batchGenerations,
imageGenerations,
type BatchGeneration,
type NewBatchGeneration,
batchGenerations,
imageGenerations,
type BatchGeneration,
type NewBatchGeneration,
} from '../db/schema';
import { CreateBatchDto, GetBatchQueryDto } from './dto/batch.dto';
export interface BatchWithItems extends BatchGeneration {
items?: {
id: string;
index: number;
prompt: string;
status: string;
errorMessage?: string | null;
retryCount?: number;
imageUrl?: string | null;
}[];
items?: {
id: string;
index: number;
prompt: string;
status: string;
errorMessage?: string | null;
retryCount?: number;
imageUrl?: string | null;
}[];
}
@Injectable()
export class BatchService {
private readonly logger = new Logger(BatchService.name);
private readonly logger = new Logger(BatchService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
/**
* Create a new batch generation
*/
async createBatch(
userId: string,
dto: CreateBatchDto,
): Promise<BatchWithItems> {
try {
// Create the batch record
const [batch] = await this.db
.insert(batchGenerations)
.values({
userId,
name: dto.batchName || `Batch ${new Date().toLocaleString()}`,
totalCount: dto.prompts.length,
completedCount: 0,
failedCount: 0,
processingCount: 0,
pendingCount: dto.prompts.length,
status: 'pending',
modelId: dto.sharedSettings.modelId,
modelVersion: dto.sharedSettings.modelVersion,
width: dto.sharedSettings.width,
height: dto.sharedSettings.height,
steps: dto.sharedSettings.steps,
guidanceScale: dto.sharedSettings.guidanceScale,
} as NewBatchGeneration)
.returning();
/**
* Create a new batch generation
*/
async createBatch(userId: string, dto: CreateBatchDto): Promise<BatchWithItems> {
try {
// Create the batch record
const [batch] = await this.db
.insert(batchGenerations)
.values({
userId,
name: dto.batchName || `Batch ${new Date().toLocaleString()}`,
totalCount: dto.prompts.length,
completedCount: 0,
failedCount: 0,
processingCount: 0,
pendingCount: dto.prompts.length,
status: 'pending',
modelId: dto.sharedSettings.modelId,
modelVersion: dto.sharedSettings.modelVersion,
width: dto.sharedSettings.width,
height: dto.sharedSettings.height,
steps: dto.sharedSettings.steps,
guidanceScale: dto.sharedSettings.guidanceScale,
} as NewBatchGeneration)
.returning();
// Create individual generation records for each prompt
const generationRecords = dto.prompts.map((prompt, index) => ({
userId,
batchId: batch.id,
prompt: prompt.text,
negativePrompt: prompt.negativePrompt,
seed: prompt.seed,
model: dto.sharedSettings.modelVersion,
width: dto.sharedSettings.width,
height: dto.sharedSettings.height,
steps: dto.sharedSettings.steps,
guidanceScale: dto.sharedSettings.guidanceScale,
status: 'pending' as const,
priority: index,
}));
// Create individual generation records for each prompt
const generationRecords = dto.prompts.map((prompt, index) => ({
userId,
batchId: batch.id,
prompt: prompt.text,
negativePrompt: prompt.negativePrompt,
seed: prompt.seed,
model: dto.sharedSettings.modelVersion,
width: dto.sharedSettings.width,
height: dto.sharedSettings.height,
steps: dto.sharedSettings.steps,
guidanceScale: dto.sharedSettings.guidanceScale,
status: 'pending' as const,
priority: index,
}));
const generations = await this.db
.insert(imageGenerations)
.values(generationRecords)
.returning();
const generations = await this.db
.insert(imageGenerations)
.values(generationRecords)
.returning();
// Return batch with items
return {
...batch,
items: generations.map((gen, index) => ({
id: gen.id,
index,
prompt: gen.prompt,
status: gen.status,
errorMessage: gen.errorMessage,
retryCount: gen.retryCount,
imageUrl: null,
})),
};
} catch (error) {
this.logger.error('Error creating batch', error);
throw error;
}
}
// Return batch with items
return {
...batch,
items: generations.map((gen, index) => ({
id: gen.id,
index,
prompt: gen.prompt,
status: gen.status,
errorMessage: gen.errorMessage,
retryCount: gen.retryCount,
imageUrl: null,
})),
};
} catch (error) {
this.logger.error('Error creating batch', error);
throw error;
}
}
/**
* Get a batch by ID with its items
*/
async getBatch(batchId: string, userId: string): Promise<BatchWithItems> {
try {
// Get batch
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
/**
* Get a batch by ID with its items
*/
async getBatch(batchId: string, userId: string): Promise<BatchWithItems> {
try {
// Get batch
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Get items
const items = await this.db
.select({
id: imageGenerations.id,
prompt: imageGenerations.prompt,
status: imageGenerations.status,
errorMessage: imageGenerations.errorMessage,
retryCount: imageGenerations.retryCount,
priority: imageGenerations.priority,
})
.from(imageGenerations)
.where(eq(imageGenerations.batchId, batchId))
.orderBy(imageGenerations.priority);
// Get items
const items = await this.db
.select({
id: imageGenerations.id,
prompt: imageGenerations.prompt,
status: imageGenerations.status,
errorMessage: imageGenerations.errorMessage,
retryCount: imageGenerations.retryCount,
priority: imageGenerations.priority,
})
.from(imageGenerations)
.where(eq(imageGenerations.batchId, batchId))
.orderBy(imageGenerations.priority);
return {
...batch,
items: items.map((item, index) => ({
id: item.id,
index,
prompt: item.prompt,
status: item.status,
errorMessage: item.errorMessage,
retryCount: item.retryCount ?? 0,
imageUrl: null, // TODO: Join with images table to get URL
})),
};
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error getting batch ${batchId}`, error);
throw error;
}
}
return {
...batch,
items: items.map((item, index) => ({
id: item.id,
index,
prompt: item.prompt,
status: item.status,
errorMessage: item.errorMessage,
retryCount: item.retryCount ?? 0,
imageUrl: null, // TODO: Join with images table to get URL
})),
};
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error getting batch ${batchId}`, error);
throw error;
}
}
/**
* Get all batches for a user
*/
async getUserBatches(
userId: string,
query: GetBatchQueryDto,
): Promise<BatchGeneration[]> {
try {
const { page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
/**
* Get all batches for a user
*/
async getUserBatches(userId: string, query: GetBatchQueryDto): Promise<BatchGeneration[]> {
try {
const { page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
const batches = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.userId, userId))
.orderBy(desc(batchGenerations.createdAt))
.limit(limit)
.offset(offset);
const batches = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.userId, userId))
.orderBy(desc(batchGenerations.createdAt))
.limit(limit)
.offset(offset);
return batches;
} catch (error) {
this.logger.error('Error getting user batches', error);
throw error;
}
}
return batches;
} catch (error) {
this.logger.error('Error getting user batches', error);
throw error;
}
}
/**
* Get batch progress (counts)
*/
async getBatchProgress(
batchId: string,
userId: string,
): Promise<{
totalCount: number;
completedCount: number;
failedCount: number;
processingCount: number;
pendingCount: number;
status: string;
}> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
/**
* Get batch progress (counts)
*/
async getBatchProgress(
batchId: string,
userId: string
): Promise<{
totalCount: number;
completedCount: number;
failedCount: number;
processingCount: number;
pendingCount: number;
status: string;
}> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Get actual counts from image_generations
const counts = await this.db
.select({
status: imageGenerations.status,
count: sql<number>`count(*)`,
})
.from(imageGenerations)
.where(eq(imageGenerations.batchId, batchId))
.groupBy(imageGenerations.status);
// Get actual counts from image_generations
const counts = await this.db
.select({
status: imageGenerations.status,
count: sql<number>`count(*)`,
})
.from(imageGenerations)
.where(eq(imageGenerations.batchId, batchId))
.groupBy(imageGenerations.status);
const statusCounts: Record<string, number> = {};
counts.forEach((c) => {
statusCounts[c.status] = Number(c.count);
});
const statusCounts: Record<string, number> = {};
counts.forEach((c) => {
statusCounts[c.status] = Number(c.count);
});
const completedCount = statusCounts['completed'] || 0;
const failedCount = statusCounts['failed'] || 0;
const processingCount = statusCounts['processing'] || 0;
const pendingCount =
(statusCounts['pending'] || 0) + (statusCounts['queued'] || 0);
const completedCount = statusCounts['completed'] || 0;
const failedCount = statusCounts['failed'] || 0;
const processingCount = statusCounts['processing'] || 0;
const pendingCount = (statusCounts['pending'] || 0) + (statusCounts['queued'] || 0);
// Determine overall status
let status: string = batch.status;
const totalCount = batch.totalCount;
// Determine overall status
let status: string = batch.status;
const totalCount = batch.totalCount;
if (completedCount === totalCount) {
status = 'completed';
} else if (failedCount === totalCount) {
status = 'failed';
} else if (completedCount > 0 && failedCount > 0) {
status = 'partial';
} else if (processingCount > 0 || pendingCount > 0) {
status = 'processing';
}
if (completedCount === totalCount) {
status = 'completed';
} else if (failedCount === totalCount) {
status = 'failed';
} else if (completedCount > 0 && failedCount > 0) {
status = 'partial';
} else if (processingCount > 0 || pendingCount > 0) {
status = 'processing';
}
// Update batch if status changed
if (status !== batch.status) {
await this.db
.update(batchGenerations)
.set({
status: status as any,
completedCount,
failedCount,
processingCount,
pendingCount,
completedAt:
status === 'completed' || status === 'failed'
? new Date()
: null,
})
.where(eq(batchGenerations.id, batchId));
}
// Update batch if status changed
if (status !== batch.status) {
await this.db
.update(batchGenerations)
.set({
status: status as any,
completedCount,
failedCount,
processingCount,
pendingCount,
completedAt: status === 'completed' || status === 'failed' ? new Date() : null,
})
.where(eq(batchGenerations.id, batchId));
}
return {
totalCount,
completedCount,
failedCount,
processingCount,
pendingCount,
status,
};
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error getting batch progress ${batchId}`, error);
throw error;
}
}
return {
totalCount,
completedCount,
failedCount,
processingCount,
pendingCount,
status,
};
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error getting batch progress ${batchId}`, error);
throw error;
}
}
/**
* Retry failed generations in a batch
*/
async retryFailed(batchId: string, userId: string): Promise<{ affected: number }> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
/**
* Retry failed generations in a batch
*/
async retryFailed(batchId: string, userId: string): Promise<{ affected: number }> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Reset failed generations to pending
const result = await this.db
.update(imageGenerations)
.set({
status: 'pending',
errorMessage: null,
retryCount: 0,
})
.where(
and(
eq(imageGenerations.batchId, batchId),
eq(imageGenerations.status, 'failed'),
),
)
.returning();
// Reset failed generations to pending
const result = await this.db
.update(imageGenerations)
.set({
status: 'pending',
errorMessage: null,
retryCount: 0,
})
.where(and(eq(imageGenerations.batchId, batchId), eq(imageGenerations.status, 'failed')))
.returning();
// Update batch status
await this.db
.update(batchGenerations)
.set({
status: 'processing',
failedCount: 0,
})
.where(eq(batchGenerations.id, batchId));
// Update batch status
await this.db
.update(batchGenerations)
.set({
status: 'processing',
failedCount: 0,
})
.where(eq(batchGenerations.id, batchId));
return { affected: result.length };
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error retrying batch ${batchId}`, error);
throw error;
}
}
return { affected: result.length };
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error retrying batch ${batchId}`, error);
throw error;
}
}
/**
* Cancel a batch
*/
async cancelBatch(batchId: string, userId: string): Promise<void> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
/**
* Cancel a batch
*/
async cancelBatch(batchId: string, userId: string): Promise<void> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Cancel pending generations
await this.db
.update(imageGenerations)
.set({
status: 'cancelled',
errorMessage: 'Cancelled by user',
})
.where(
and(
eq(imageGenerations.batchId, batchId),
eq(imageGenerations.status, 'pending'),
),
);
// Cancel pending generations
await this.db
.update(imageGenerations)
.set({
status: 'cancelled',
errorMessage: 'Cancelled by user',
})
.where(and(eq(imageGenerations.batchId, batchId), eq(imageGenerations.status, 'pending')));
// Update batch status
await this.db
.update(batchGenerations)
.set({
status: 'failed',
completedAt: new Date(),
})
.where(eq(batchGenerations.id, batchId));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error cancelling batch ${batchId}`, error);
throw error;
}
}
// Update batch status
await this.db
.update(batchGenerations)
.set({
status: 'failed',
completedAt: new Date(),
})
.where(eq(batchGenerations.id, batchId));
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error cancelling batch ${batchId}`, error);
throw error;
}
}
/**
* Delete a batch and all its generations
*/
async deleteBatch(batchId: string, userId: string): Promise<void> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
/**
* Delete a batch and all its generations
*/
async deleteBatch(batchId: string, userId: string): Promise<void> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Delete generations first
await this.db
.delete(imageGenerations)
.where(eq(imageGenerations.batchId, batchId));
// Delete generations first
await this.db.delete(imageGenerations).where(eq(imageGenerations.batchId, batchId));
// Delete batch
await this.db
.delete(batchGenerations)
.where(eq(batchGenerations.id, batchId));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error deleting batch ${batchId}`, error);
throw error;
}
}
// Delete batch
await this.db.delete(batchGenerations).where(eq(batchGenerations.id, batchId));
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error deleting batch ${batchId}`, error);
throw error;
}
}
}

View file

@ -1,74 +1,67 @@
import {
IsString,
IsOptional,
IsNumber,
IsArray,
ValidateNested,
IsUUID,
} from 'class-validator';
import { IsString, IsOptional, IsNumber, IsArray, ValidateNested, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
export class BatchPromptDto {
@IsString()
text: string;
@IsString()
text: string;
@IsString()
@IsOptional()
negativePrompt?: string;
@IsString()
@IsOptional()
negativePrompt?: string;
@IsNumber()
@IsOptional()
seed?: number;
@IsNumber()
@IsOptional()
seed?: number;
@IsArray()
@IsString({ each: true })
@IsOptional()
tags?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
tags?: string[];
}
export class SharedSettingsDto {
@IsUUID()
modelId: string;
@IsUUID()
modelId: string;
@IsString()
modelVersion: string;
@IsString()
modelVersion: string;
@IsNumber()
width: number;
@IsNumber()
width: number;
@IsNumber()
height: number;
@IsNumber()
height: number;
@IsNumber()
steps: number;
@IsNumber()
steps: number;
@IsNumber()
guidanceScale: number;
@IsNumber()
guidanceScale: number;
}
export class CreateBatchDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => BatchPromptDto)
prompts: BatchPromptDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => BatchPromptDto)
prompts: BatchPromptDto[];
@ValidateNested()
@Type(() => SharedSettingsDto)
sharedSettings: SharedSettingsDto;
@ValidateNested()
@Type(() => SharedSettingsDto)
sharedSettings: SharedSettingsDto;
@IsString()
@IsOptional()
batchName?: string;
@IsString()
@IsOptional()
batchName?: string;
}
export class GetBatchQueryDto {
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
}

View file

@ -1,114 +1,81 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { BoardItemService } from './board-item.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import {
AddImageToBoardDto,
AddTextToBoardDto,
UpdateBoardItemDto,
UpdateBoardItemsDto,
RemoveBoardItemsDto,
ChangeZIndexDto,
AddImageToBoardDto,
AddTextToBoardDto,
UpdateBoardItemDto,
UpdateBoardItemsDto,
RemoveBoardItemsDto,
ChangeZIndexDto,
} from './dto/board-item.dto';
@Controller('board-items')
@UseGuards(JwtAuthGuard)
export class BoardItemController {
constructor(private readonly boardItemService: BoardItemService) {}
constructor(private readonly boardItemService: BoardItemService) {}
@Get('board/:boardId')
async getBoardItems(
@CurrentUser() user: CurrentUserData,
@Param('boardId') boardId: string,
) {
return this.boardItemService.getBoardItems(boardId, user.userId);
}
@Get('board/:boardId')
async getBoardItems(@CurrentUser() user: CurrentUserData, @Param('boardId') boardId: string) {
return this.boardItemService.getBoardItems(boardId, user.userId);
}
@Get(':id')
async getBoardItemById(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardItemService.getBoardItemById(id, user.userId);
}
@Get(':id')
async getBoardItemById(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.boardItemService.getBoardItemById(id, user.userId);
}
@Post('image')
async addImageToBoard(
@CurrentUser() user: CurrentUserData,
@Body() dto: AddImageToBoardDto,
) {
return this.boardItemService.addImageToBoard(user.userId, dto);
}
@Post('image')
async addImageToBoard(@CurrentUser() user: CurrentUserData, @Body() dto: AddImageToBoardDto) {
return this.boardItemService.addImageToBoard(user.userId, dto);
}
@Post('text')
async addTextToBoard(
@CurrentUser() user: CurrentUserData,
@Body() dto: AddTextToBoardDto,
) {
return this.boardItemService.addTextToBoard(user.userId, dto);
}
@Post('text')
async addTextToBoard(@CurrentUser() user: CurrentUserData, @Body() dto: AddTextToBoardDto) {
return this.boardItemService.addTextToBoard(user.userId, dto);
}
@Patch(':id')
async updateBoardItem(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateBoardItemDto,
) {
return this.boardItemService.updateBoardItem(id, user.userId, dto);
}
@Patch(':id')
async updateBoardItem(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateBoardItemDto
) {
return this.boardItemService.updateBoardItem(id, user.userId, dto);
}
@Patch('batch')
async updateBoardItems(
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateBoardItemsDto,
) {
return this.boardItemService.updateBoardItems(user.userId, dto.items);
}
@Patch('batch')
async updateBoardItems(@CurrentUser() user: CurrentUserData, @Body() dto: UpdateBoardItemsDto) {
return this.boardItemService.updateBoardItems(user.userId, dto.items);
}
@Delete(':id')
async removeBoardItem(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardItemService.removeBoardItem(id, user.userId);
}
@Delete(':id')
async removeBoardItem(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.boardItemService.removeBoardItem(id, user.userId);
}
@Delete('batch')
async removeBoardItems(
@CurrentUser() user: CurrentUserData,
@Body() dto: RemoveBoardItemsDto,
) {
return this.boardItemService.removeBoardItems(user.userId, dto.ids);
}
@Delete('batch')
async removeBoardItems(@CurrentUser() user: CurrentUserData, @Body() dto: RemoveBoardItemsDto) {
return this.boardItemService.removeBoardItems(user.userId, dto.ids);
}
@Patch(':id/z-index')
async changeZIndex(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: ChangeZIndexDto,
) {
return this.boardItemService.changeZIndex(id, user.userId, dto.direction);
}
@Patch(':id/z-index')
async changeZIndex(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: ChangeZIndexDto
) {
return this.boardItemService.changeZIndex(id, user.userId, dto.direction);
}
@Get('check/:boardId/:imageId')
async isImageOnBoard(
@CurrentUser() user: CurrentUserData,
@Param('boardId') boardId: string,
@Param('imageId') imageId: string,
) {
const result = await this.boardItemService.isImageOnBoard(boardId, imageId);
return { isOnBoard: result };
}
@Get('check/:boardId/:imageId')
async isImageOnBoard(
@CurrentUser() user: CurrentUserData,
@Param('boardId') boardId: string,
@Param('imageId') imageId: string
) {
const result = await this.boardItemService.isImageOnBoard(boardId, imageId);
return { isOnBoard: result };
}
}

View file

@ -3,8 +3,8 @@ import { BoardItemController } from './board-item.controller';
import { BoardItemService } from './board-item.service';
@Module({
controllers: [BoardItemController],
providers: [BoardItemService],
exports: [BoardItemService],
controllers: [BoardItemController],
providers: [BoardItemService],
exports: [BoardItemService],
})
export class BoardItemModule {}

View file

@ -1,515 +1,429 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { eq, and, max, inArray, gt, lt, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { boards, boardItems, images, type BoardItem } from '../db/schema';
import {
AddImageToBoardDto,
AddTextToBoardDto,
UpdateBoardItemDto,
} from './dto/board-item.dto';
import { AddImageToBoardDto, AddTextToBoardDto, UpdateBoardItemDto } from './dto/board-item.dto';
export interface BoardItemWithImage extends BoardItem {
image?: {
id: string;
publicUrl: string | null;
width: number | null;
height: number | null;
prompt: string;
blurhash: string | null;
};
image?: {
id: string;
publicUrl: string | null;
width: number | null;
height: number | null;
prompt: string;
blurhash: string | null;
};
}
@Injectable()
export class BoardItemService {
private readonly logger = new Logger(BoardItemService.name);
private readonly logger = new Logger(BoardItemService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getBoardItems(
boardId: string,
userId: string,
): Promise<BoardItemWithImage[]> {
try {
await this.verifyBoardAccess(boardId, userId);
async getBoardItems(boardId: string, userId: string): Promise<BoardItemWithImage[]> {
try {
await this.verifyBoardAccess(boardId, userId);
const result = await this.db
.select({
id: boardItems.id,
boardId: boardItems.boardId,
imageId: boardItems.imageId,
itemType: boardItems.itemType,
positionX: boardItems.positionX,
positionY: boardItems.positionY,
scaleX: boardItems.scaleX,
scaleY: boardItems.scaleY,
rotation: boardItems.rotation,
zIndex: boardItems.zIndex,
opacity: boardItems.opacity,
width: boardItems.width,
height: boardItems.height,
textContent: boardItems.textContent,
fontSize: boardItems.fontSize,
color: boardItems.color,
properties: boardItems.properties,
createdAt: boardItems.createdAt,
image: {
id: images.id,
publicUrl: images.publicUrl,
width: images.width,
height: images.height,
prompt: images.prompt,
blurhash: images.blurhash,
},
})
.from(boardItems)
.leftJoin(images, eq(boardItems.imageId, images.id))
.where(eq(boardItems.boardId, boardId))
.orderBy(boardItems.zIndex);
const result = await this.db
.select({
id: boardItems.id,
boardId: boardItems.boardId,
imageId: boardItems.imageId,
itemType: boardItems.itemType,
positionX: boardItems.positionX,
positionY: boardItems.positionY,
scaleX: boardItems.scaleX,
scaleY: boardItems.scaleY,
rotation: boardItems.rotation,
zIndex: boardItems.zIndex,
opacity: boardItems.opacity,
width: boardItems.width,
height: boardItems.height,
textContent: boardItems.textContent,
fontSize: boardItems.fontSize,
color: boardItems.color,
properties: boardItems.properties,
createdAt: boardItems.createdAt,
image: {
id: images.id,
publicUrl: images.publicUrl,
width: images.width,
height: images.height,
prompt: images.prompt,
blurhash: images.blurhash,
},
})
.from(boardItems)
.leftJoin(images, eq(boardItems.imageId, images.id))
.where(eq(boardItems.boardId, boardId))
.orderBy(boardItems.zIndex);
return result as BoardItemWithImage[];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error fetching board items for board ${boardId}`, error);
throw error;
}
}
return result as BoardItemWithImage[];
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error fetching board items for board ${boardId}`, error);
throw error;
}
}
async getBoardItemById(
id: string,
userId: string,
): Promise<BoardItemWithImage> {
try {
const result = await this.db
.select({
id: boardItems.id,
boardId: boardItems.boardId,
imageId: boardItems.imageId,
itemType: boardItems.itemType,
positionX: boardItems.positionX,
positionY: boardItems.positionY,
scaleX: boardItems.scaleX,
scaleY: boardItems.scaleY,
rotation: boardItems.rotation,
zIndex: boardItems.zIndex,
opacity: boardItems.opacity,
width: boardItems.width,
height: boardItems.height,
textContent: boardItems.textContent,
fontSize: boardItems.fontSize,
color: boardItems.color,
properties: boardItems.properties,
createdAt: boardItems.createdAt,
image: {
id: images.id,
publicUrl: images.publicUrl,
width: images.width,
height: images.height,
prompt: images.prompt,
blurhash: images.blurhash,
},
})
.from(boardItems)
.leftJoin(images, eq(boardItems.imageId, images.id))
.where(eq(boardItems.id, id))
.limit(1);
async getBoardItemById(id: string, userId: string): Promise<BoardItemWithImage> {
try {
const result = await this.db
.select({
id: boardItems.id,
boardId: boardItems.boardId,
imageId: boardItems.imageId,
itemType: boardItems.itemType,
positionX: boardItems.positionX,
positionY: boardItems.positionY,
scaleX: boardItems.scaleX,
scaleY: boardItems.scaleY,
rotation: boardItems.rotation,
zIndex: boardItems.zIndex,
opacity: boardItems.opacity,
width: boardItems.width,
height: boardItems.height,
textContent: boardItems.textContent,
fontSize: boardItems.fontSize,
color: boardItems.color,
properties: boardItems.properties,
createdAt: boardItems.createdAt,
image: {
id: images.id,
publicUrl: images.publicUrl,
width: images.width,
height: images.height,
prompt: images.prompt,
blurhash: images.blurhash,
},
})
.from(boardItems)
.leftJoin(images, eq(boardItems.imageId, images.id))
.where(eq(boardItems.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
const item = result[0];
await this.verifyBoardAccess(item.boardId, userId);
const item = result[0];
await this.verifyBoardAccess(item.boardId, userId);
return item as BoardItemWithImage;
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error fetching board item ${id}`, error);
throw error;
}
}
return item as BoardItemWithImage;
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error fetching board item ${id}`, error);
throw error;
}
}
async addImageToBoard(
userId: string,
dto: AddImageToBoardDto,
): Promise<BoardItem> {
try {
await this.verifyBoardOwnership(dto.boardId, userId);
async addImageToBoard(userId: string, dto: AddImageToBoardDto): Promise<BoardItem> {
try {
await this.verifyBoardOwnership(dto.boardId, userId);
const nextZIndex = await this.getNextZIndex(dto.boardId);
const nextZIndex = await this.getNextZIndex(dto.boardId);
const result = await this.db
.insert(boardItems)
.values({
boardId: dto.boardId,
imageId: dto.imageId,
itemType: 'image',
positionX: dto.positionX || 100,
positionY: dto.positionY || 100,
zIndex: nextZIndex,
})
.returning();
const result = await this.db
.insert(boardItems)
.values({
boardId: dto.boardId,
imageId: dto.imageId,
itemType: 'image',
positionX: dto.positionX || 100,
positionY: dto.positionY || 100,
zIndex: nextZIndex,
})
.returning();
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, dto.boardId));
// Update board's updatedAt
await this.db.update(boards).set({ updatedAt: new Date() }).where(eq(boards.id, dto.boardId));
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error('Error adding image to board', error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error('Error adding image to board', error);
throw error;
}
}
async addTextToBoard(
userId: string,
dto: AddTextToBoardDto,
): Promise<BoardItem> {
try {
await this.verifyBoardOwnership(dto.boardId, userId);
async addTextToBoard(userId: string, dto: AddTextToBoardDto): Promise<BoardItem> {
try {
await this.verifyBoardOwnership(dto.boardId, userId);
const nextZIndex = await this.getNextZIndex(dto.boardId);
const nextZIndex = await this.getNextZIndex(dto.boardId);
const result = await this.db
.insert(boardItems)
.values({
boardId: dto.boardId,
itemType: 'text',
positionX: dto.positionX || 100,
positionY: dto.positionY || 100,
textContent: dto.content || 'New Text',
fontSize: dto.fontSize || 24,
color: dto.color || '#000000',
properties: dto.properties,
zIndex: nextZIndex,
})
.returning();
const result = await this.db
.insert(boardItems)
.values({
boardId: dto.boardId,
itemType: 'text',
positionX: dto.positionX || 100,
positionY: dto.positionY || 100,
textContent: dto.content || 'New Text',
fontSize: dto.fontSize || 24,
color: dto.color || '#000000',
properties: dto.properties,
zIndex: nextZIndex,
})
.returning();
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, dto.boardId));
// Update board's updatedAt
await this.db.update(boards).set({ updatedAt: new Date() }).where(eq(boards.id, dto.boardId));
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error('Error adding text to board', error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error('Error adding text to board', error);
throw error;
}
}
async updateBoardItem(
id: string,
userId: string,
dto: UpdateBoardItemDto,
): Promise<BoardItem> {
try {
const item = await this.db
.select()
.from(boardItems)
.where(eq(boardItems.id, id))
.limit(1);
async updateBoardItem(id: string, userId: string, dto: UpdateBoardItemDto): Promise<BoardItem> {
try {
const item = await this.db.select().from(boardItems).where(eq(boardItems.id, id)).limit(1);
if (item.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
if (item.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
await this.verifyBoardOwnership(item[0].boardId, userId);
await this.verifyBoardOwnership(item[0].boardId, userId);
const result = await this.db
.update(boardItems)
.set({
...(dto.positionX !== undefined && { positionX: dto.positionX }),
...(dto.positionY !== undefined && { positionY: dto.positionY }),
...(dto.scaleX !== undefined && { scaleX: dto.scaleX }),
...(dto.scaleY !== undefined && { scaleY: dto.scaleY }),
...(dto.rotation !== undefined && { rotation: dto.rotation }),
...(dto.zIndex !== undefined && { zIndex: dto.zIndex }),
...(dto.opacity !== undefined && { opacity: dto.opacity }),
...(dto.width !== undefined && { width: dto.width }),
...(dto.height !== undefined && { height: dto.height }),
...(dto.textContent !== undefined && { textContent: dto.textContent }),
...(dto.fontSize !== undefined && { fontSize: dto.fontSize }),
...(dto.color !== undefined && { color: dto.color }),
...(dto.properties !== undefined && { properties: dto.properties }),
})
.where(eq(boardItems.id, id))
.returning();
const result = await this.db
.update(boardItems)
.set({
...(dto.positionX !== undefined && { positionX: dto.positionX }),
...(dto.positionY !== undefined && { positionY: dto.positionY }),
...(dto.scaleX !== undefined && { scaleX: dto.scaleX }),
...(dto.scaleY !== undefined && { scaleY: dto.scaleY }),
...(dto.rotation !== undefined && { rotation: dto.rotation }),
...(dto.zIndex !== undefined && { zIndex: dto.zIndex }),
...(dto.opacity !== undefined && { opacity: dto.opacity }),
...(dto.width !== undefined && { width: dto.width }),
...(dto.height !== undefined && { height: dto.height }),
...(dto.textContent !== undefined && { textContent: dto.textContent }),
...(dto.fontSize !== undefined && { fontSize: dto.fontSize }),
...(dto.color !== undefined && { color: dto.color }),
...(dto.properties !== undefined && { properties: dto.properties }),
})
.where(eq(boardItems.id, id))
.returning();
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, item[0].boardId));
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, item[0].boardId));
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error updating board item ${id}`, error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error updating board item ${id}`, error);
throw error;
}
}
async updateBoardItems(
userId: string,
items: Array<{ id: string } & UpdateBoardItemDto>,
): Promise<void> {
try {
for (const item of items) {
await this.updateBoardItem(item.id, userId, item);
}
} catch (error) {
this.logger.error('Error batch updating board items', error);
throw error;
}
}
async updateBoardItems(
userId: string,
items: Array<{ id: string } & UpdateBoardItemDto>
): Promise<void> {
try {
for (const item of items) {
await this.updateBoardItem(item.id, userId, item);
}
} catch (error) {
this.logger.error('Error batch updating board items', error);
throw error;
}
}
async removeBoardItem(id: string, userId: string): Promise<void> {
try {
const item = await this.db
.select()
.from(boardItems)
.where(eq(boardItems.id, id))
.limit(1);
async removeBoardItem(id: string, userId: string): Promise<void> {
try {
const item = await this.db.select().from(boardItems).where(eq(boardItems.id, id)).limit(1);
if (item.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
if (item.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
await this.verifyBoardOwnership(item[0].boardId, userId);
await this.verifyBoardOwnership(item[0].boardId, userId);
await this.db.delete(boardItems).where(eq(boardItems.id, id));
await this.db.delete(boardItems).where(eq(boardItems.id, id));
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, item[0].boardId));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error removing board item ${id}`, error);
throw error;
}
}
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, item[0].boardId));
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error removing board item ${id}`, error);
throw error;
}
}
async removeBoardItems(userId: string, ids: string[]): Promise<void> {
try {
for (const id of ids) {
await this.removeBoardItem(id, userId);
}
} catch (error) {
this.logger.error('Error batch removing board items', error);
throw error;
}
}
async removeBoardItems(userId: string, ids: string[]): Promise<void> {
try {
for (const id of ids) {
await this.removeBoardItem(id, userId);
}
} catch (error) {
this.logger.error('Error batch removing board items', error);
throw error;
}
}
async changeZIndex(
id: string,
userId: string,
direction: 'up' | 'down' | 'top' | 'bottom',
): Promise<BoardItem> {
try {
const item = await this.db
.select()
.from(boardItems)
.where(eq(boardItems.id, id))
.limit(1);
async changeZIndex(
id: string,
userId: string,
direction: 'up' | 'down' | 'top' | 'bottom'
): Promise<BoardItem> {
try {
const item = await this.db.select().from(boardItems).where(eq(boardItems.id, id)).limit(1);
if (item.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
if (item.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
await this.verifyBoardOwnership(item[0].boardId, userId);
await this.verifyBoardOwnership(item[0].boardId, userId);
const currentZIndex = item[0].zIndex;
let newZIndex: number;
const currentZIndex = item[0].zIndex;
let newZIndex: number;
if (direction === 'top') {
const maxResult = await this.db
.select({ maxZ: max(boardItems.zIndex) })
.from(boardItems)
.where(eq(boardItems.boardId, item[0].boardId));
newZIndex = (maxResult[0]?.maxZ || 0) + 1;
} else if (direction === 'bottom') {
newZIndex = 0;
// Shift all other items up
await this.db
.update(boardItems)
.set({ zIndex: sql`${boardItems.zIndex} + 1` })
.where(eq(boardItems.boardId, item[0].boardId));
} else if (direction === 'up') {
// Find the next item above
const above = await this.db
.select()
.from(boardItems)
.where(
and(
eq(boardItems.boardId, item[0].boardId),
gt(boardItems.zIndex, currentZIndex),
),
)
.orderBy(boardItems.zIndex)
.limit(1);
if (direction === 'top') {
const maxResult = await this.db
.select({ maxZ: max(boardItems.zIndex) })
.from(boardItems)
.where(eq(boardItems.boardId, item[0].boardId));
newZIndex = (maxResult[0]?.maxZ || 0) + 1;
} else if (direction === 'bottom') {
newZIndex = 0;
// Shift all other items up
await this.db
.update(boardItems)
.set({ zIndex: sql`${boardItems.zIndex} + 1` })
.where(eq(boardItems.boardId, item[0].boardId));
} else if (direction === 'up') {
// Find the next item above
const above = await this.db
.select()
.from(boardItems)
.where(and(eq(boardItems.boardId, item[0].boardId), gt(boardItems.zIndex, currentZIndex)))
.orderBy(boardItems.zIndex)
.limit(1);
if (above.length > 0) {
// Swap z-indices
await this.db
.update(boardItems)
.set({ zIndex: currentZIndex })
.where(eq(boardItems.id, above[0].id));
newZIndex = above[0].zIndex;
} else {
newZIndex = currentZIndex;
}
} else {
// down
const below = await this.db
.select()
.from(boardItems)
.where(
and(
eq(boardItems.boardId, item[0].boardId),
lt(boardItems.zIndex, currentZIndex),
),
)
.orderBy(boardItems.zIndex)
.limit(1);
if (above.length > 0) {
// Swap z-indices
await this.db
.update(boardItems)
.set({ zIndex: currentZIndex })
.where(eq(boardItems.id, above[0].id));
newZIndex = above[0].zIndex;
} else {
newZIndex = currentZIndex;
}
} else {
// down
const below = await this.db
.select()
.from(boardItems)
.where(and(eq(boardItems.boardId, item[0].boardId), lt(boardItems.zIndex, currentZIndex)))
.orderBy(boardItems.zIndex)
.limit(1);
if (below.length > 0) {
// Swap z-indices
await this.db
.update(boardItems)
.set({ zIndex: currentZIndex })
.where(eq(boardItems.id, below[0].id));
newZIndex = below[0].zIndex;
} else {
newZIndex = currentZIndex;
}
}
if (below.length > 0) {
// Swap z-indices
await this.db
.update(boardItems)
.set({ zIndex: currentZIndex })
.where(eq(boardItems.id, below[0].id));
newZIndex = below[0].zIndex;
} else {
newZIndex = currentZIndex;
}
}
const result = await this.db
.update(boardItems)
.set({ zIndex: newZIndex })
.where(eq(boardItems.id, id))
.returning();
const result = await this.db
.update(boardItems)
.set({ zIndex: newZIndex })
.where(eq(boardItems.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error changing z-index for board item ${id}`, error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error changing z-index for board item ${id}`, error);
throw error;
}
}
async isImageOnBoard(boardId: string, imageId: string): Promise<boolean> {
try {
const result = await this.db
.select()
.from(boardItems)
.where(
and(eq(boardItems.boardId, boardId), eq(boardItems.imageId, imageId)),
)
.limit(1);
async isImageOnBoard(boardId: string, imageId: string): Promise<boolean> {
try {
const result = await this.db
.select()
.from(boardItems)
.where(and(eq(boardItems.boardId, boardId), eq(boardItems.imageId, imageId)))
.limit(1);
return result.length > 0;
} catch (error) {
this.logger.error(
`Error checking if image ${imageId} is on board ${boardId}`,
error,
);
throw error;
}
}
return result.length > 0;
} catch (error) {
this.logger.error(`Error checking if image ${imageId} is on board ${boardId}`, error);
throw error;
}
}
private async getNextZIndex(boardId: string): Promise<number> {
const result = await this.db
.select({ maxZ: max(boardItems.zIndex) })
.from(boardItems)
.where(eq(boardItems.boardId, boardId));
private async getNextZIndex(boardId: string): Promise<number> {
const result = await this.db
.select({ maxZ: max(boardItems.zIndex) })
.from(boardItems)
.where(eq(boardItems.boardId, boardId));
return (result[0]?.maxZ || 0) + 1;
}
return (result[0]?.maxZ || 0) + 1;
}
private async verifyBoardAccess(
boardId: string,
userId: string,
): Promise<void> {
const result = await this.db
.select({ userId: boards.userId, isPublic: boards.isPublic })
.from(boards)
.where(eq(boards.id, boardId))
.limit(1);
private async verifyBoardAccess(boardId: string, userId: string): Promise<void> {
const result = await this.db
.select({ userId: boards.userId, isPublic: boards.isPublic })
.from(boards)
.where(eq(boards.id, boardId))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board with id ${boardId} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Board with id ${boardId} not found`);
}
if (result[0].userId !== userId && !result[0].isPublic) {
throw new ForbiddenException('Access denied');
}
}
if (result[0].userId !== userId && !result[0].isPublic) {
throw new ForbiddenException('Access denied');
}
}
private async verifyBoardOwnership(
boardId: string,
userId: string,
): Promise<void> {
const result = await this.db
.select({ userId: boards.userId })
.from(boards)
.where(eq(boards.id, boardId))
.limit(1);
private async verifyBoardOwnership(boardId: string, userId: string): Promise<void> {
const result = await this.db
.select({ userId: boards.userId })
.from(boards)
.where(eq(boards.id, boardId))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board with id ${boardId} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Board with id ${boardId} not found`);
}
if (result[0].userId !== userId) {
throw new ForbiddenException('Access denied');
}
}
if (result[0].userId !== userId) {
throw new ForbiddenException('Access denied');
}
}
}

View file

@ -1,134 +1,134 @@
import {
IsString,
IsOptional,
IsNumber,
IsArray,
ValidateNested,
IsObject,
IsIn,
IsString,
IsOptional,
IsNumber,
IsArray,
ValidateNested,
IsObject,
IsIn,
} from 'class-validator';
import { Type } from 'class-transformer';
import { TextProperties } from '../../db/schema/board-items.schema';
export class AddImageToBoardDto {
@IsString()
boardId: string;
@IsString()
boardId: string;
@IsString()
imageId: string;
@IsString()
imageId: string;
@IsNumber()
@IsOptional()
positionX?: number;
@IsNumber()
@IsOptional()
positionX?: number;
@IsNumber()
@IsOptional()
positionY?: number;
@IsNumber()
@IsOptional()
positionY?: number;
}
export class AddTextToBoardDto {
@IsString()
boardId: string;
@IsString()
boardId: string;
@IsString()
@IsOptional()
content?: string;
@IsString()
@IsOptional()
content?: string;
@IsNumber()
@IsOptional()
positionX?: number;
@IsNumber()
@IsOptional()
positionX?: number;
@IsNumber()
@IsOptional()
positionY?: number;
@IsNumber()
@IsOptional()
positionY?: number;
@IsNumber()
@IsOptional()
fontSize?: number;
@IsNumber()
@IsOptional()
fontSize?: number;
@IsString()
@IsOptional()
color?: string;
@IsString()
@IsOptional()
color?: string;
@IsObject()
@IsOptional()
properties?: TextProperties;
@IsObject()
@IsOptional()
properties?: TextProperties;
}
export class UpdateBoardItemDto {
@IsNumber()
@IsOptional()
positionX?: number;
@IsNumber()
@IsOptional()
positionX?: number;
@IsNumber()
@IsOptional()
positionY?: number;
@IsNumber()
@IsOptional()
positionY?: number;
@IsNumber()
@IsOptional()
scaleX?: number;
@IsNumber()
@IsOptional()
scaleX?: number;
@IsNumber()
@IsOptional()
scaleY?: number;
@IsNumber()
@IsOptional()
scaleY?: number;
@IsNumber()
@IsOptional()
rotation?: number;
@IsNumber()
@IsOptional()
rotation?: number;
@IsNumber()
@IsOptional()
zIndex?: number;
@IsNumber()
@IsOptional()
zIndex?: number;
@IsNumber()
@IsOptional()
opacity?: number;
@IsNumber()
@IsOptional()
opacity?: number;
@IsNumber()
@IsOptional()
width?: number;
@IsNumber()
@IsOptional()
width?: number;
@IsNumber()
@IsOptional()
height?: number;
@IsNumber()
@IsOptional()
height?: number;
@IsString()
@IsOptional()
textContent?: string;
@IsString()
@IsOptional()
textContent?: string;
@IsNumber()
@IsOptional()
fontSize?: number;
@IsNumber()
@IsOptional()
fontSize?: number;
@IsString()
@IsOptional()
color?: string;
@IsString()
@IsOptional()
color?: string;
@IsObject()
@IsOptional()
properties?: TextProperties;
@IsObject()
@IsOptional()
properties?: TextProperties;
}
export class UpdateBoardItemWithIdDto extends UpdateBoardItemDto {
@IsString()
id: string;
@IsString()
id: string;
}
export class UpdateBoardItemsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => UpdateBoardItemWithIdDto)
items: UpdateBoardItemWithIdDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => UpdateBoardItemWithIdDto)
items: UpdateBoardItemWithIdDto[];
}
export class RemoveBoardItemsDto {
@IsArray()
@IsString({ each: true })
ids: string[];
@IsArray()
@IsString({ each: true })
ids: string[];
}
export class ChangeZIndexDto {
@IsString()
@IsIn(['up', 'down', 'top', 'bottom'])
direction: 'up' | 'down' | 'top' | 'bottom';
@IsString()
@IsIn(['up', 'down', 'top', 'bottom'])
direction: 'up' | 'down' | 'top' | 'bottom';
}

View file

@ -1,102 +1,84 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Query,
Body,
UseGuards,
Controller,
Get,
Post,
Patch,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import { BoardService } from './board.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import {
CreateBoardDto,
UpdateBoardDto,
GetBoardsQueryDto,
GenerateThumbnailDto,
ToggleVisibilityDto,
CreateBoardDto,
UpdateBoardDto,
GetBoardsQueryDto,
GenerateThumbnailDto,
ToggleVisibilityDto,
} from './dto/board.dto';
@Controller('boards')
@UseGuards(JwtAuthGuard)
export class BoardController {
constructor(private readonly boardService: BoardService) {}
constructor(private readonly boardService: BoardService) {}
@Get()
async getBoards(
@CurrentUser() user: CurrentUserData,
@Query() query: GetBoardsQueryDto,
) {
return this.boardService.getBoards(user.userId, query);
}
@Get()
async getBoards(@CurrentUser() user: CurrentUserData, @Query() query: GetBoardsQueryDto) {
return this.boardService.getBoards(user.userId, query);
}
@Get('public')
async getPublicBoards(@Query() query: GetBoardsQueryDto) {
return this.boardService.getPublicBoards(query);
}
@Get('public')
async getPublicBoards(@Query() query: GetBoardsQueryDto) {
return this.boardService.getPublicBoards(query);
}
@Get(':id')
async getBoardById(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardService.getBoardById(id, user.userId);
}
@Get(':id')
async getBoardById(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.boardService.getBoardById(id, user.userId);
}
@Post()
async createBoard(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreateBoardDto,
) {
return this.boardService.createBoard(user.userId, dto);
}
@Post()
async createBoard(@CurrentUser() user: CurrentUserData, @Body() dto: CreateBoardDto) {
return this.boardService.createBoard(user.userId, dto);
}
@Patch(':id')
async updateBoard(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateBoardDto,
) {
return this.boardService.updateBoard(id, user.userId, dto);
}
@Patch(':id')
async updateBoard(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateBoardDto
) {
return this.boardService.updateBoard(id, user.userId, dto);
}
@Delete(':id')
async deleteBoard(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardService.deleteBoard(id, user.userId);
}
@Delete(':id')
async deleteBoard(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.boardService.deleteBoard(id, user.userId);
}
@Post(':id/duplicate')
async duplicateBoard(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardService.duplicateBoard(id, user.userId);
}
@Post(':id/duplicate')
async duplicateBoard(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.boardService.duplicateBoard(id, user.userId);
}
@Post(':id/thumbnail')
async generateThumbnail(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: GenerateThumbnailDto,
) {
return this.boardService.generateThumbnail(id, user.userId, dto.dataUrl);
}
@Post(':id/thumbnail')
async generateThumbnail(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: GenerateThumbnailDto
) {
return this.boardService.generateThumbnail(id, user.userId, dto.dataUrl);
}
@Patch(':id/visibility')
async toggleVisibility(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: ToggleVisibilityDto,
) {
return this.boardService.toggleVisibility(id, user.userId, dto.isPublic);
}
@Patch(':id/visibility')
async toggleVisibility(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: ToggleVisibilityDto
) {
return this.boardService.toggleVisibility(id, user.userId, dto.isPublic);
}
}

View file

@ -3,8 +3,8 @@ import { BoardController } from './board.controller';
import { BoardService } from './board.service';
@Module({
controllers: [BoardController],
providers: [BoardService],
exports: [BoardService],
controllers: [BoardController],
providers: [BoardService],
exports: [BoardService],
})
export class BoardModule {}

View file

@ -1,10 +1,4 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { eq, and, or, desc, sql } from 'drizzle-orm';
import { ConfigService } from '@nestjs/config';
import { createClient } from '@supabase/supabase-js';
@ -14,390 +8,344 @@ import { boards, boardItems, type Board } from '../db/schema';
import { CreateBoardDto, UpdateBoardDto, GetBoardsQueryDto } from './dto/board.dto';
export interface BoardWithCount extends Board {
itemCount: number;
itemCount: number;
}
@Injectable()
export class BoardService {
private readonly logger = new Logger(BoardService.name);
private supabase: ReturnType<typeof createClient>;
private readonly logger = new Logger(BoardService.name);
private supabase: ReturnType<typeof createClient>;
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private configService: ConfigService,
) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL');
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
if (supabaseUrl && supabaseKey) {
this.supabase = createClient(supabaseUrl, supabaseKey);
}
}
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private configService: ConfigService
) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL');
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
if (supabaseUrl && supabaseKey) {
this.supabase = createClient(supabaseUrl, supabaseKey);
}
}
async getBoards(
userId: string,
query: GetBoardsQueryDto,
): Promise<BoardWithCount[]> {
try {
const { page = 1, limit = 20, includePublic = false } = query;
const offset = (page - 1) * limit;
async getBoards(userId: string, query: GetBoardsQueryDto): Promise<BoardWithCount[]> {
try {
const { page = 1, limit = 20, includePublic = false } = query;
const offset = (page - 1) * limit;
const conditions = includePublic
? or(eq(boards.userId, userId), eq(boards.isPublic, true))
: eq(boards.userId, userId);
const conditions = includePublic
? or(eq(boards.userId, userId), eq(boards.isPublic, true))
: eq(boards.userId, userId);
const result = await this.db
.select({
id: boards.id,
userId: boards.userId,
name: boards.name,
description: boards.description,
thumbnailUrl: boards.thumbnailUrl,
canvasWidth: boards.canvasWidth,
canvasHeight: boards.canvasHeight,
backgroundColor: boards.backgroundColor,
isPublic: boards.isPublic,
createdAt: boards.createdAt,
updatedAt: boards.updatedAt,
itemCount: sql<number>`(
const result = await this.db
.select({
id: boards.id,
userId: boards.userId,
name: boards.name,
description: boards.description,
thumbnailUrl: boards.thumbnailUrl,
canvasWidth: boards.canvasWidth,
canvasHeight: boards.canvasHeight,
backgroundColor: boards.backgroundColor,
isPublic: boards.isPublic,
createdAt: boards.createdAt,
updatedAt: boards.updatedAt,
itemCount: sql<number>`(
SELECT COUNT(*)::int FROM ${boardItems}
WHERE ${boardItems.boardId} = ${boards.id}
)`,
})
.from(boards)
.where(conditions)
.orderBy(desc(boards.updatedAt))
.limit(limit)
.offset(offset);
})
.from(boards)
.where(conditions)
.orderBy(desc(boards.updatedAt))
.limit(limit)
.offset(offset);
return result as BoardWithCount[];
} catch (error) {
this.logger.error('Error fetching boards', error);
throw error;
}
}
return result as BoardWithCount[];
} catch (error) {
this.logger.error('Error fetching boards', error);
throw error;
}
}
async getPublicBoards(query: GetBoardsQueryDto): Promise<BoardWithCount[]> {
try {
const { page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
async getPublicBoards(query: GetBoardsQueryDto): Promise<BoardWithCount[]> {
try {
const { page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
const result = await this.db
.select({
id: boards.id,
userId: boards.userId,
name: boards.name,
description: boards.description,
thumbnailUrl: boards.thumbnailUrl,
canvasWidth: boards.canvasWidth,
canvasHeight: boards.canvasHeight,
backgroundColor: boards.backgroundColor,
isPublic: boards.isPublic,
createdAt: boards.createdAt,
updatedAt: boards.updatedAt,
itemCount: sql<number>`(
const result = await this.db
.select({
id: boards.id,
userId: boards.userId,
name: boards.name,
description: boards.description,
thumbnailUrl: boards.thumbnailUrl,
canvasWidth: boards.canvasWidth,
canvasHeight: boards.canvasHeight,
backgroundColor: boards.backgroundColor,
isPublic: boards.isPublic,
createdAt: boards.createdAt,
updatedAt: boards.updatedAt,
itemCount: sql<number>`(
SELECT COUNT(*)::int FROM ${boardItems}
WHERE ${boardItems.boardId} = ${boards.id}
)`,
})
.from(boards)
.where(eq(boards.isPublic, true))
.orderBy(desc(boards.updatedAt))
.limit(limit)
.offset(offset);
})
.from(boards)
.where(eq(boards.isPublic, true))
.orderBy(desc(boards.updatedAt))
.limit(limit)
.offset(offset);
return result as BoardWithCount[];
} catch (error) {
this.logger.error('Error fetching public boards', error);
throw error;
}
}
return result as BoardWithCount[];
} catch (error) {
this.logger.error('Error fetching public boards', error);
throw error;
}
}
async getBoardById(id: string, userId: string): Promise<Board> {
try {
const result = await this.db
.select()
.from(boards)
.where(eq(boards.id, id))
.limit(1);
async getBoardById(id: string, userId: string): Promise<Board> {
try {
const result = await this.db.select().from(boards).where(eq(boards.id, id)).limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board with id ${id} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Board with id ${id} not found`);
}
const board = result[0];
const board = result[0];
// Check access
if (board.userId !== userId && !board.isPublic) {
throw new ForbiddenException('Access denied');
}
// Check access
if (board.userId !== userId && !board.isPublic) {
throw new ForbiddenException('Access denied');
}
return board;
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error fetching board ${id}`, error);
throw error;
}
}
return board;
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error fetching board ${id}`, error);
throw error;
}
}
async createBoard(userId: string, dto: CreateBoardDto): Promise<Board> {
try {
const result = await this.db
.insert(boards)
.values({
userId,
name: dto.name,
description: dto.description,
canvasWidth: dto.canvasWidth || 2000,
canvasHeight: dto.canvasHeight || 1500,
backgroundColor: dto.backgroundColor || '#ffffff',
isPublic: dto.isPublic || false,
})
.returning();
async createBoard(userId: string, dto: CreateBoardDto): Promise<Board> {
try {
const result = await this.db
.insert(boards)
.values({
userId,
name: dto.name,
description: dto.description,
canvasWidth: dto.canvasWidth || 2000,
canvasHeight: dto.canvasHeight || 1500,
backgroundColor: dto.backgroundColor || '#ffffff',
isPublic: dto.isPublic || false,
})
.returning();
return result[0];
} catch (error) {
this.logger.error('Error creating board', error);
throw error;
}
}
return result[0];
} catch (error) {
this.logger.error('Error creating board', error);
throw error;
}
}
async updateBoard(
id: string,
userId: string,
dto: UpdateBoardDto,
): Promise<Board> {
try {
await this.verifyOwnership(id, userId);
async updateBoard(id: string, userId: string, dto: UpdateBoardDto): Promise<Board> {
try {
await this.verifyOwnership(id, userId);
const result = await this.db
.update(boards)
.set({
...(dto.name && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.canvasWidth && { canvasWidth: dto.canvasWidth }),
...(dto.canvasHeight && { canvasHeight: dto.canvasHeight }),
...(dto.backgroundColor && { backgroundColor: dto.backgroundColor }),
...(dto.isPublic !== undefined && { isPublic: dto.isPublic }),
updatedAt: new Date(),
})
.where(eq(boards.id, id))
.returning();
const result = await this.db
.update(boards)
.set({
...(dto.name && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.canvasWidth && { canvasWidth: dto.canvasWidth }),
...(dto.canvasHeight && { canvasHeight: dto.canvasHeight }),
...(dto.backgroundColor && { backgroundColor: dto.backgroundColor }),
...(dto.isPublic !== undefined && { isPublic: dto.isPublic }),
updatedAt: new Date(),
})
.where(eq(boards.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error updating board ${id}`, error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error updating board ${id}`, error);
throw error;
}
}
async deleteBoard(id: string, userId: string): Promise<void> {
try {
await this.verifyOwnership(id, userId);
async deleteBoard(id: string, userId: string): Promise<void> {
try {
await this.verifyOwnership(id, userId);
// Delete board items first
await this.db.delete(boardItems).where(eq(boardItems.boardId, id));
// Delete board items first
await this.db.delete(boardItems).where(eq(boardItems.boardId, id));
// Delete the board
await this.db.delete(boards).where(eq(boards.id, id));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error deleting board ${id}`, error);
throw error;
}
}
// Delete the board
await this.db.delete(boards).where(eq(boards.id, id));
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error deleting board ${id}`, error);
throw error;
}
}
async duplicateBoard(id: string, userId: string): Promise<Board> {
try {
// Get original board (user can duplicate public boards too)
const original = await this.db
.select()
.from(boards)
.where(eq(boards.id, id))
.limit(1);
async duplicateBoard(id: string, userId: string): Promise<Board> {
try {
// Get original board (user can duplicate public boards too)
const original = await this.db.select().from(boards).where(eq(boards.id, id)).limit(1);
if (original.length === 0) {
throw new NotFoundException(`Board with id ${id} not found`);
}
if (original.length === 0) {
throw new NotFoundException(`Board with id ${id} not found`);
}
const board = original[0];
const board = original[0];
// Check access
if (board.userId !== userId && !board.isPublic) {
throw new ForbiddenException('Access denied');
}
// Check access
if (board.userId !== userId && !board.isPublic) {
throw new ForbiddenException('Access denied');
}
// Create new board
const newBoard = await this.db
.insert(boards)
.values({
userId,
name: `${board.name} (Copy)`,
description: board.description,
canvasWidth: board.canvasWidth,
canvasHeight: board.canvasHeight,
backgroundColor: board.backgroundColor,
isPublic: false,
})
.returning();
// Create new board
const newBoard = await this.db
.insert(boards)
.values({
userId,
name: `${board.name} (Copy)`,
description: board.description,
canvasWidth: board.canvasWidth,
canvasHeight: board.canvasHeight,
backgroundColor: board.backgroundColor,
isPublic: false,
})
.returning();
// Copy board items
const items = await this.db
.select()
.from(boardItems)
.where(eq(boardItems.boardId, id));
// Copy board items
const items = await this.db.select().from(boardItems).where(eq(boardItems.boardId, id));
if (items.length > 0) {
await this.db.insert(boardItems).values(
items.map((item) => ({
boardId: newBoard[0].id,
imageId: item.imageId,
itemType: item.itemType,
positionX: item.positionX,
positionY: item.positionY,
scaleX: item.scaleX,
scaleY: item.scaleY,
rotation: item.rotation,
zIndex: item.zIndex,
opacity: item.opacity,
width: item.width,
height: item.height,
textContent: item.textContent,
fontSize: item.fontSize,
color: item.color,
properties: item.properties,
})),
);
}
if (items.length > 0) {
await this.db.insert(boardItems).values(
items.map((item) => ({
boardId: newBoard[0].id,
imageId: item.imageId,
itemType: item.itemType,
positionX: item.positionX,
positionY: item.positionY,
scaleX: item.scaleX,
scaleY: item.scaleY,
rotation: item.rotation,
zIndex: item.zIndex,
opacity: item.opacity,
width: item.width,
height: item.height,
textContent: item.textContent,
fontSize: item.fontSize,
color: item.color,
properties: item.properties,
}))
);
}
return newBoard[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error duplicating board ${id}`, error);
throw error;
}
}
return newBoard[0];
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error duplicating board ${id}`, error);
throw error;
}
}
async generateThumbnail(
id: string,
userId: string,
dataUrl: string,
): Promise<Board> {
try {
await this.verifyOwnership(id, userId);
async generateThumbnail(id: string, userId: string, dataUrl: string): Promise<Board> {
try {
await this.verifyOwnership(id, userId);
if (!this.supabase) {
throw new Error('Supabase not configured');
}
if (!this.supabase) {
throw new Error('Supabase not configured');
}
// Convert data URL to buffer
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Convert data URL to buffer
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Upload to Supabase Storage
const filename = `boards/${id}/thumbnail-${Date.now()}.png`;
const { error: uploadError } = await this.supabase.storage
.from('user-uploads')
.upload(filename, buffer, {
contentType: 'image/png',
upsert: true,
});
// Upload to Supabase Storage
const filename = `boards/${id}/thumbnail-${Date.now()}.png`;
const { error: uploadError } = await this.supabase.storage
.from('user-uploads')
.upload(filename, buffer, {
contentType: 'image/png',
upsert: true,
});
if (uploadError) {
throw uploadError;
}
if (uploadError) {
throw uploadError;
}
// Get public URL
const { data: urlData } = this.supabase.storage
.from('user-uploads')
.getPublicUrl(filename);
// Get public URL
const { data: urlData } = this.supabase.storage.from('user-uploads').getPublicUrl(filename);
// Update board with thumbnail URL
const result = await this.db
.update(boards)
.set({
thumbnailUrl: urlData.publicUrl,
updatedAt: new Date(),
})
.where(eq(boards.id, id))
.returning();
// Update board with thumbnail URL
const result = await this.db
.update(boards)
.set({
thumbnailUrl: urlData.publicUrl,
updatedAt: new Date(),
})
.where(eq(boards.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error generating thumbnail for board ${id}`, error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error generating thumbnail for board ${id}`, error);
throw error;
}
}
async toggleVisibility(
id: string,
userId: string,
isPublic: boolean,
): Promise<Board> {
try {
await this.verifyOwnership(id, userId);
async toggleVisibility(id: string, userId: string, isPublic: boolean): Promise<Board> {
try {
await this.verifyOwnership(id, userId);
const result = await this.db
.update(boards)
.set({
isPublic,
updatedAt: new Date(),
})
.where(eq(boards.id, id))
.returning();
const result = await this.db
.update(boards)
.set({
isPublic,
updatedAt: new Date(),
})
.where(eq(boards.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error toggling visibility for board ${id}`, error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error toggling visibility for board ${id}`, error);
throw error;
}
}
private async verifyOwnership(id: string, userId: string): Promise<void> {
const result = await this.db
.select({ userId: boards.userId })
.from(boards)
.where(eq(boards.id, id))
.limit(1);
private async verifyOwnership(id: string, userId: string): Promise<void> {
const result = await this.db
.select({ userId: boards.userId })
.from(boards)
.where(eq(boards.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board with id ${id} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Board with id ${id} not found`);
}
if (result[0].userId !== userId) {
throw new ForbiddenException('Access denied');
}
}
if (result[0].userId !== userId) {
throw new ForbiddenException('Access denied');
}
}
}

View file

@ -2,79 +2,79 @@ import { IsString, IsOptional, IsBoolean, IsNumber } from 'class-validator';
import { Transform, Type } from 'class-transformer';
export class GetBoardsQueryDto {
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
includePublic?: boolean = false;
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
includePublic?: boolean = false;
}
export class CreateBoardDto {
@IsString()
name: string;
@IsString()
name: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
description?: string;
@IsNumber()
@IsOptional()
canvasWidth?: number;
@IsNumber()
@IsOptional()
canvasWidth?: number;
@IsNumber()
@IsOptional()
canvasHeight?: number;
@IsNumber()
@IsOptional()
canvasHeight?: number;
@IsString()
@IsOptional()
backgroundColor?: string;
@IsString()
@IsOptional()
backgroundColor?: string;
@IsBoolean()
@IsOptional()
isPublic?: boolean;
@IsBoolean()
@IsOptional()
isPublic?: boolean;
}
export class UpdateBoardDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
description?: string;
@IsNumber()
@IsOptional()
canvasWidth?: number;
@IsNumber()
@IsOptional()
canvasWidth?: number;
@IsNumber()
@IsOptional()
canvasHeight?: number;
@IsNumber()
@IsOptional()
canvasHeight?: number;
@IsString()
@IsOptional()
backgroundColor?: string;
@IsString()
@IsOptional()
backgroundColor?: string;
@IsBoolean()
@IsOptional()
isPublic?: boolean;
@IsBoolean()
@IsOptional()
isPublic?: boolean;
}
export class GenerateThumbnailDto {
@IsString()
dataUrl: string;
@IsString()
dataUrl: string;
}
export class ToggleVisibilityDto {
@IsBoolean()
isPublic: boolean;
@IsBoolean()
isPublic: boolean;
}

View file

@ -1,15 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
userId: string;
email: string;
role: string;
sessionId?: string;
userId: string;
email: string;
role: string;
sessionId?: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
}
);

View file

@ -1,66 +1,60 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') ||
'http://localhost:3001';
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
const { valid, payload } = await response.json();
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -9,30 +9,30 @@ let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -6,23 +6,23 @@ export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -5,22 +5,22 @@ import { getDb, closeConnection } from './connection';
dotenv.config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
const db = getDb(databaseUrl);
const db = getDb(databaseUrl);
console.log('Running migrations...');
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './src/db/migrations' });
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations complete!');
await closeConnection();
console.log('Migrations complete!');
await closeConnection();
}
runMigrations().catch((error) => {
console.error('Migration failed:', error);
process.exit(1);
console.error('Migration failed:', error);
process.exit(1);
});

View file

@ -1,20 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764242583001,
"tag": "0000_clever_clint_barton",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1764250151767,
"tag": "0001_woozy_human_torch",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764242583001,
"tag": "0000_clever_clint_barton",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1764250151767,
"tag": "0001_woozy_human_torch",
"breakpoints": true
}
]
}

View file

@ -1,45 +1,36 @@
import {
pgTable,
uuid,
text,
timestamp,
integer,
pgEnum,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp, integer, pgEnum } from 'drizzle-orm/pg-core';
export const batchStatusEnum = pgEnum('batch_status', [
'pending',
'processing',
'completed',
'partial',
'failed',
'pending',
'processing',
'completed',
'partial',
'failed',
]);
export const batchGenerations = pgTable('batch_generations', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name'),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name'),
totalCount: integer('total_count').notNull(),
completedCount: integer('completed_count').default(0).notNull(),
failedCount: integer('failed_count').default(0).notNull(),
processingCount: integer('processing_count').default(0).notNull(),
pendingCount: integer('pending_count').default(0).notNull(),
totalCount: integer('total_count').notNull(),
completedCount: integer('completed_count').default(0).notNull(),
failedCount: integer('failed_count').default(0).notNull(),
processingCount: integer('processing_count').default(0).notNull(),
pendingCount: integer('pending_count').default(0).notNull(),
status: batchStatusEnum('status').default('pending').notNull(),
status: batchStatusEnum('status').default('pending').notNull(),
// Shared settings for all generations in the batch
modelId: uuid('model_id'),
modelVersion: text('model_version'),
width: integer('width'),
height: integer('height'),
steps: integer('steps'),
guidanceScale: integer('guidance_scale'),
// Shared settings for all generations in the batch
modelId: uuid('model_id'),
modelVersion: text('model_version'),
width: integer('width'),
height: integer('height'),
steps: integer('steps'),
guidanceScale: integer('guidance_scale'),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
});
export type BatchGeneration = typeof batchGenerations.$inferSelect;

View file

@ -1,49 +1,38 @@
import {
pgTable,
uuid,
text,
timestamp,
integer,
real,
jsonb,
pgEnum,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp, integer, real, jsonb, pgEnum } from 'drizzle-orm/pg-core';
export const itemTypeEnum = pgEnum('item_type', ['image', 'text']);
export interface TextProperties {
fontFamily?: string;
fontWeight?: 'normal' | 'bold';
fontStyle?: 'normal' | 'italic';
textAlign?: 'left' | 'center' | 'right';
lineHeight?: number;
fontFamily?: string;
fontWeight?: 'normal' | 'bold';
fontStyle?: 'normal' | 'italic';
textAlign?: 'left' | 'center' | 'right';
lineHeight?: number;
}
export const boardItems = pgTable('board_items', {
id: uuid('id').primaryKey().defaultRandom(),
boardId: uuid('board_id').notNull(),
imageId: uuid('image_id'),
id: uuid('id').primaryKey().defaultRandom(),
boardId: uuid('board_id').notNull(),
imageId: uuid('image_id'),
itemType: itemTypeEnum('item_type').default('image').notNull(),
itemType: itemTypeEnum('item_type').default('image').notNull(),
positionX: real('position_x').default(0).notNull(),
positionY: real('position_y').default(0).notNull(),
scaleX: real('scale_x').default(1).notNull(),
scaleY: real('scale_y').default(1).notNull(),
rotation: real('rotation').default(0).notNull(),
zIndex: integer('z_index').default(0).notNull(),
opacity: real('opacity').default(1).notNull(),
width: integer('width'),
height: integer('height'),
positionX: real('position_x').default(0).notNull(),
positionY: real('position_y').default(0).notNull(),
scaleX: real('scale_x').default(1).notNull(),
scaleY: real('scale_y').default(1).notNull(),
rotation: real('rotation').default(0).notNull(),
zIndex: integer('z_index').default(0).notNull(),
opacity: real('opacity').default(1).notNull(),
width: integer('width'),
height: integer('height'),
textContent: text('text_content'),
fontSize: integer('font_size'),
color: text('color'),
properties: jsonb('properties').$type<TextProperties>(),
textContent: text('text_content'),
fontSize: integer('font_size'),
color: text('color'),
properties: jsonb('properties').$type<TextProperties>(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type BoardItem = typeof boardItems.$inferSelect;

View file

@ -1,32 +1,21 @@
import {
pgTable,
uuid,
text,
timestamp,
boolean,
integer,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
export const boards = pgTable('boards', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
description: text('description'),
thumbnailUrl: text('thumbnail_url'),
name: text('name').notNull(),
description: text('description'),
thumbnailUrl: text('thumbnail_url'),
canvasWidth: integer('canvas_width').default(2000).notNull(),
canvasHeight: integer('canvas_height').default(1500).notNull(),
backgroundColor: text('background_color').default('#ffffff').notNull(),
canvasWidth: integer('canvas_width').default(2000).notNull(),
canvasHeight: integer('canvas_height').default(1500).notNull(),
backgroundColor: text('background_color').default('#ffffff').notNull(),
isPublic: boolean('is_public').default(false).notNull(),
isPublic: boolean('is_public').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Board = typeof boards.$inferSelect;

View file

@ -1,52 +1,42 @@
import {
pgTable,
uuid,
text,
timestamp,
integer,
real,
pgEnum,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp, integer, real, pgEnum } from 'drizzle-orm/pg-core';
export const generationStatusEnum = pgEnum('generation_status', [
'pending',
'queued',
'processing',
'completed',
'failed',
'cancelled',
'pending',
'queued',
'processing',
'completed',
'failed',
'cancelled',
]);
export const imageGenerations = pgTable('image_generations', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
modelId: uuid('model_id'),
batchId: uuid('batch_id'),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
modelId: uuid('model_id'),
batchId: uuid('batch_id'),
prompt: text('prompt').notNull(),
negativePrompt: text('negative_prompt'),
model: text('model'),
style: text('style'),
sourceImageUrl: text('source_image_url'),
prompt: text('prompt').notNull(),
negativePrompt: text('negative_prompt'),
model: text('model'),
style: text('style'),
sourceImageUrl: text('source_image_url'),
width: integer('width'),
height: integer('height'),
steps: integer('steps'),
guidanceScale: real('guidance_scale'),
seed: integer('seed'),
generationStrength: real('generation_strength'),
width: integer('width'),
height: integer('height'),
steps: integer('steps'),
guidanceScale: real('guidance_scale'),
seed: integer('seed'),
generationStrength: real('generation_strength'),
status: generationStatusEnum('status').default('pending').notNull(),
replicatePredictionId: text('replicate_prediction_id'),
errorMessage: text('error_message'),
generationTimeSeconds: integer('generation_time_seconds'),
retryCount: integer('retry_count').default(0).notNull(),
priority: integer('priority').default(0).notNull(),
status: generationStatusEnum('status').default('pending').notNull(),
replicatePredictionId: text('replicate_prediction_id'),
errorMessage: text('error_message'),
generationTimeSeconds: integer('generation_time_seconds'),
retryCount: integer('retry_count').default(0).notNull(),
priority: integer('priority').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
});
export type ImageGeneration = typeof imageGenerations.$inferSelect;

View file

@ -1,26 +1,19 @@
import {
pgTable,
uuid,
timestamp,
unique,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, timestamp, unique } from 'drizzle-orm/pg-core';
import { images } from './images.schema';
export const imageLikes = pgTable(
'image_likes',
{
id: uuid('id').primaryKey().defaultRandom(),
imageId: uuid('image_id')
.notNull()
.references(() => images.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
uniqueImageUser: unique('unique_image_user').on(table.imageId, table.userId),
}),
'image_likes',
{
id: uuid('id').primaryKey().defaultRandom(),
imageId: uuid('image_id')
.notNull()
.references(() => images.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
uniqueImageUser: unique('unique_image_user').on(table.imageId, table.userId),
})
);
export type ImageLike = typeof imageLikes.$inferSelect;

View file

@ -1,45 +1,34 @@
import {
pgTable,
uuid,
text,
timestamp,
boolean,
integer,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
export const images = pgTable('images', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
generationId: uuid('generation_id'),
sourceImageId: uuid('source_image_id'),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
generationId: uuid('generation_id'),
sourceImageId: uuid('source_image_id'),
prompt: text('prompt').notNull(),
negativePrompt: text('negative_prompt'),
model: text('model'),
style: text('style'),
prompt: text('prompt').notNull(),
negativePrompt: text('negative_prompt'),
model: text('model'),
style: text('style'),
publicUrl: text('public_url'),
storagePath: text('storage_path').notNull(),
filename: text('filename').notNull(),
format: text('format'),
publicUrl: text('public_url'),
storagePath: text('storage_path').notNull(),
filename: text('filename').notNull(),
format: text('format'),
width: integer('width'),
height: integer('height'),
fileSize: integer('file_size'),
blurhash: text('blurhash'),
width: integer('width'),
height: integer('height'),
fileSize: integer('file_size'),
blurhash: text('blurhash'),
isPublic: boolean('is_public').default(false).notNull(),
isFavorite: boolean('is_favorite').default(false).notNull(),
downloadCount: integer('download_count').default(0).notNull(),
rating: integer('rating'),
isPublic: boolean('is_public').default(false).notNull(),
isFavorite: boolean('is_favorite').default(false).notNull(),
downloadCount: integer('download_count').default(0).notNull(),
rating: integer('rating'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull(),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Image = typeof images.$inferSelect;

View file

@ -1,50 +1,36 @@
import {
pgTable,
uuid,
text,
timestamp,
boolean,
integer,
real,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp, boolean, integer, real } from 'drizzle-orm/pg-core';
export const models = pgTable('models', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
replicateId: text('replicate_id').notNull(),
version: text('version'),
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
replicateId: text('replicate_id').notNull(),
version: text('version'),
defaultWidth: integer('default_width').default(1024),
defaultHeight: integer('default_height').default(1024),
defaultSteps: integer('default_steps').default(25),
defaultGuidanceScale: real('default_guidance_scale').default(7.5),
defaultWidth: integer('default_width').default(1024),
defaultHeight: integer('default_height').default(1024),
defaultSteps: integer('default_steps').default(25),
defaultGuidanceScale: real('default_guidance_scale').default(7.5),
minWidth: integer('min_width').default(512),
minHeight: integer('min_height').default(512),
maxWidth: integer('max_width').default(2048),
maxHeight: integer('max_height').default(2048),
maxSteps: integer('max_steps').default(50),
minWidth: integer('min_width').default(512),
minHeight: integer('min_height').default(512),
maxWidth: integer('max_width').default(2048),
maxHeight: integer('max_height').default(2048),
maxSteps: integer('max_steps').default(50),
supportsNegativePrompt: boolean('supports_negative_prompt')
.default(true)
.notNull(),
supportsImg2Img: boolean('supports_img2img').default(false).notNull(),
supportsSeed: boolean('supports_seed').default(true).notNull(),
supportsNegativePrompt: boolean('supports_negative_prompt').default(true).notNull(),
supportsImg2Img: boolean('supports_img2img').default(false).notNull(),
supportsSeed: boolean('supports_seed').default(true).notNull(),
isActive: boolean('is_active').default(true).notNull(),
isDefault: boolean('is_default').default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
costPerGeneration: real('cost_per_generation'),
estimatedTimeSeconds: integer('estimated_time_seconds'),
isActive: boolean('is_active').default(true).notNull(),
isDefault: boolean('is_default').default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
costPerGeneration: real('cost_per_generation'),
estimatedTimeSeconds: integer('estimated_time_seconds'),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Model = typeof models.$inferSelect;

View file

@ -1,21 +1,12 @@
import {
pgTable,
uuid,
text,
timestamp,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
export const profiles = pgTable('profiles', {
id: uuid('id').primaryKey(), // Same as auth user id
username: text('username'),
email: text('email').notNull(),
avatarUrl: text('avatar_url'),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull(),
id: uuid('id').primaryKey(), // Same as auth user id
username: text('username'),
email: text('email').notNull(),
avatarUrl: text('avatar_url'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Profile = typeof profiles.$inferSelect;

View file

@ -1,22 +1,20 @@
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
export const tags = pgTable('tags', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
export const imageTags = pgTable('image_tags', {
id: uuid('id').primaryKey().defaultRandom(),
imageId: uuid('image_id').notNull(),
tagId: uuid('tag_id').notNull(),
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
id: uuid('id').primaryKey().defaultRandom(),
imageId: uuid('image_id').notNull(),
tagId: uuid('tag_id').notNull(),
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
});
export type ImageTag = typeof imageTags.$inferSelect;

View file

@ -5,86 +5,86 @@ import { models } from './schema';
dotenv.config();
const defaultModels = [
{
name: 'sdxl',
displayName: 'Stable Diffusion XL',
description: 'High-quality image generation with excellent prompt adherence',
replicateId: 'stability-ai/sdxl',
version: '39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b',
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 25,
defaultGuidanceScale: 7.5,
supportsNegativePrompt: true,
supportsImg2Img: true,
supportsSeed: true,
isActive: true,
isDefault: true,
sortOrder: 0,
estimatedTimeSeconds: 15,
},
{
name: 'flux-schnell',
displayName: 'FLUX Schnell',
description: 'Fast image generation with good quality',
replicateId: 'black-forest-labs/flux-schnell',
version: 'f2ab8a5bfe79f02f0789a146cf5e73d2a4ff2684a98c2b303d1e1ff3814271db',
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 4,
defaultGuidanceScale: 0,
supportsNegativePrompt: false,
supportsImg2Img: false,
supportsSeed: true,
isActive: true,
isDefault: false,
sortOrder: 1,
estimatedTimeSeconds: 5,
},
{
name: 'flux-pro',
displayName: 'FLUX Pro',
description: 'Professional quality image generation',
replicateId: 'black-forest-labs/flux-pro',
version: '7d6fbcd3da3f4e1c1c08d8ab0e7a4c2e0e5e3c9e8f8e8e8e8e8e8e8e8e8e8e8e',
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 25,
defaultGuidanceScale: 3.5,
supportsNegativePrompt: false,
supportsImg2Img: false,
supportsSeed: true,
isActive: true,
isDefault: false,
sortOrder: 2,
estimatedTimeSeconds: 20,
},
{
name: 'sdxl',
displayName: 'Stable Diffusion XL',
description: 'High-quality image generation with excellent prompt adherence',
replicateId: 'stability-ai/sdxl',
version: '39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b',
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 25,
defaultGuidanceScale: 7.5,
supportsNegativePrompt: true,
supportsImg2Img: true,
supportsSeed: true,
isActive: true,
isDefault: true,
sortOrder: 0,
estimatedTimeSeconds: 15,
},
{
name: 'flux-schnell',
displayName: 'FLUX Schnell',
description: 'Fast image generation with good quality',
replicateId: 'black-forest-labs/flux-schnell',
version: 'f2ab8a5bfe79f02f0789a146cf5e73d2a4ff2684a98c2b303d1e1ff3814271db',
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 4,
defaultGuidanceScale: 0,
supportsNegativePrompt: false,
supportsImg2Img: false,
supportsSeed: true,
isActive: true,
isDefault: false,
sortOrder: 1,
estimatedTimeSeconds: 5,
},
{
name: 'flux-pro',
displayName: 'FLUX Pro',
description: 'Professional quality image generation',
replicateId: 'black-forest-labs/flux-pro',
version: '7d6fbcd3da3f4e1c1c08d8ab0e7a4c2e0e5e3c9e8f8e8e8e8e8e8e8e8e8e8e8e',
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 25,
defaultGuidanceScale: 3.5,
supportsNegativePrompt: false,
supportsImg2Img: false,
supportsSeed: true,
isActive: true,
isDefault: false,
sortOrder: 2,
estimatedTimeSeconds: 20,
},
];
async function seed() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
const db = getDb(databaseUrl);
const db = getDb(databaseUrl);
console.log('Seeding models...');
console.log('Seeding models...');
for (const model of defaultModels) {
try {
await db.insert(models).values(model).onConflictDoNothing();
console.log(` - ${model.displayName}`);
} catch (error) {
console.error(` - Error seeding ${model.displayName}:`, error);
}
}
for (const model of defaultModels) {
try {
await db.insert(models).values(model).onConflictDoNothing();
console.log(` - ${model.displayName}`);
} catch (error) {
console.error(` - Error seeding ${model.displayName}:`, error);
}
}
console.log('Seeding complete!');
await closeConnection();
console.log('Seeding complete!');
await closeConnection();
}
seed().catch((error) => {
console.error('Seed failed:', error);
process.exit(1);
console.error('Seed failed:', error);
process.exit(1);
});

View file

@ -2,33 +2,33 @@ import { IsString, IsOptional, IsNumber, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class GetPublicImagesDto {
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsString()
@IsOptional()
@IsIn(['recent', 'popular', 'trending'])
sortBy?: 'recent' | 'popular' | 'trending' = 'recent';
@IsString()
@IsOptional()
@IsIn(['recent', 'popular', 'trending'])
sortBy?: 'recent' | 'popular' | 'trending' = 'recent';
}
export class SearchPublicImagesDto {
@IsString()
searchTerm: string;
@IsString()
searchTerm: string;
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
}

View file

@ -6,15 +6,15 @@ import { GetPublicImagesDto, SearchPublicImagesDto } from './dto/explore.dto';
@Controller('explore')
@UseGuards(JwtAuthGuard)
export class ExploreController {
constructor(private readonly exploreService: ExploreService) {}
constructor(private readonly exploreService: ExploreService) {}
@Get()
async getPublicImages(@Query() query: GetPublicImagesDto) {
return this.exploreService.getPublicImages(query);
}
@Get()
async getPublicImages(@Query() query: GetPublicImagesDto) {
return this.exploreService.getPublicImages(query);
}
@Get('search')
async searchPublicImages(@Query() query: SearchPublicImagesDto) {
return this.exploreService.searchPublicImages(query);
}
@Get('search')
async searchPublicImages(@Query() query: SearchPublicImagesDto) {
return this.exploreService.searchPublicImages(query);
}
}

View file

@ -3,7 +3,7 @@ import { ExploreController } from './explore.controller';
import { ExploreService } from './explore.service';
@Module({
controllers: [ExploreController],
providers: [ExploreService],
controllers: [ExploreController],
providers: [ExploreService],
})
export class ExploreModule {}

View file

@ -7,77 +7,74 @@ import { GetPublicImagesDto, SearchPublicImagesDto } from './dto/explore.dto';
@Injectable()
export class ExploreService {
private readonly logger = new Logger(ExploreService.name);
private readonly logger = new Logger(ExploreService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getPublicImages(query: GetPublicImagesDto): Promise<Image[]> {
try {
const { page = 1, limit = 20, sortBy = 'recent' } = query;
const offset = (page - 1) * limit;
async getPublicImages(query: GetPublicImagesDto): Promise<Image[]> {
try {
const { page = 1, limit = 20, sortBy = 'recent' } = query;
const offset = (page - 1) * limit;
const conditions = [
eq(images.isPublic, true),
isNull(images.archivedAt),
];
const conditions = [eq(images.isPublic, true), isNull(images.archivedAt)];
let orderBy;
switch (sortBy) {
case 'popular':
orderBy = desc(images.downloadCount);
break;
case 'trending':
// For trending, we could implement a more complex algorithm
// For now, just use recent with some weight on downloads
orderBy = desc(images.createdAt);
break;
case 'recent':
default:
orderBy = desc(images.createdAt);
}
let orderBy;
switch (sortBy) {
case 'popular':
orderBy = desc(images.downloadCount);
break;
case 'trending':
// For trending, we could implement a more complex algorithm
// For now, just use recent with some weight on downloads
orderBy = desc(images.createdAt);
break;
case 'recent':
default:
orderBy = desc(images.createdAt);
}
const result = await this.db
.select()
.from(images)
.where(and(...conditions))
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const result = await this.db
.select()
.from(images)
.where(and(...conditions))
.orderBy(orderBy)
.limit(limit)
.offset(offset);
return result;
} catch (error) {
this.logger.error('Error fetching public images', error);
throw error;
}
}
return result;
} catch (error) {
this.logger.error('Error fetching public images', error);
throw error;
}
}
async searchPublicImages(query: SearchPublicImagesDto): Promise<Image[]> {
try {
const { searchTerm, page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
async searchPublicImages(query: SearchPublicImagesDto): Promise<Image[]> {
try {
const { searchTerm, page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
if (!searchTerm || searchTerm.trim().length === 0) {
return this.getPublicImages({ page, limit });
}
if (!searchTerm || searchTerm.trim().length === 0) {
return this.getPublicImages({ page, limit });
}
const conditions = [
eq(images.isPublic, true),
isNull(images.archivedAt),
ilike(images.prompt, `%${searchTerm}%`),
];
const conditions = [
eq(images.isPublic, true),
isNull(images.archivedAt),
ilike(images.prompt, `%${searchTerm}%`),
];
const result = await this.db
.select()
.from(images)
.where(and(...conditions))
.orderBy(desc(images.createdAt))
.limit(limit)
.offset(offset);
const result = await this.db
.select()
.from(images)
.where(and(...conditions))
.orderBy(desc(images.createdAt))
.limit(limit)
.offset(offset);
return result;
} catch (error) {
this.logger.error('Error searching public images', error);
throw error;
}
}
return result;
} catch (error) {
this.logger.error('Error searching public images', error);
throw error;
}
}
}

View file

@ -1,53 +1,53 @@
import { IsString, IsOptional, IsNumber, IsBoolean } from 'class-validator';
export class GenerateImageDto {
@IsString()
prompt: string;
@IsString()
prompt: string;
@IsString()
modelId: string;
@IsString()
modelId: string;
@IsString()
@IsOptional()
modelVersion?: string;
@IsString()
@IsOptional()
modelVersion?: string;
@IsString()
@IsOptional()
negativePrompt?: string;
@IsString()
@IsOptional()
negativePrompt?: string;
@IsNumber()
@IsOptional()
width?: number;
@IsNumber()
@IsOptional()
width?: number;
@IsNumber()
@IsOptional()
height?: number;
@IsNumber()
@IsOptional()
height?: number;
@IsNumber()
@IsOptional()
steps?: number;
@IsNumber()
@IsOptional()
steps?: number;
@IsNumber()
@IsOptional()
guidanceScale?: number;
@IsNumber()
@IsOptional()
guidanceScale?: number;
@IsNumber()
@IsOptional()
seed?: number;
@IsNumber()
@IsOptional()
seed?: number;
@IsString()
@IsOptional()
sourceImageUrl?: string;
@IsString()
@IsOptional()
sourceImageUrl?: string;
@IsNumber()
@IsOptional()
generationStrength?: number;
@IsNumber()
@IsOptional()
generationStrength?: number;
@IsString()
@IsOptional()
style?: string;
@IsString()
@IsOptional()
style?: string;
@IsBoolean()
@IsOptional()
waitForResult?: boolean;
@IsBoolean()
@IsOptional()
waitForResult?: boolean;
}

View file

@ -1,54 +1,34 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { GenerateService } from './generate.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { GenerateImageDto } from './dto/generate.dto';
@Controller('generate')
export class GenerateController {
constructor(private readonly generateService: GenerateService) {}
constructor(private readonly generateService: GenerateService) {}
@Post()
@UseGuards(JwtAuthGuard)
async generateImage(
@CurrentUser() user: CurrentUserData,
@Body() dto: GenerateImageDto,
) {
return this.generateService.generateImage(user.userId, dto);
}
@Post()
@UseGuards(JwtAuthGuard)
async generateImage(@CurrentUser() user: CurrentUserData, @Body() dto: GenerateImageDto) {
return this.generateService.generateImage(user.userId, dto);
}
@Get(':id/status')
@UseGuards(JwtAuthGuard)
async checkStatus(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.generateService.checkStatus(id, user.userId);
}
@Get(':id/status')
@UseGuards(JwtAuthGuard)
async checkStatus(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.generateService.checkStatus(id, user.userId);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
async cancelGeneration(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.generateService.cancelGeneration(id, user.userId);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
async cancelGeneration(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.generateService.cancelGeneration(id, user.userId);
}
// Webhook endpoint for Replicate - no auth required
@Post('webhook')
async handleWebhook(@Body() body: any) {
return this.generateService.handleWebhook(body);
}
// Webhook endpoint for Replicate - no auth required
@Post('webhook')
async handleWebhook(@Body() body: any) {
return this.generateService.handleWebhook(body);
}
}

View file

@ -5,9 +5,9 @@ import { ReplicateService } from './replicate.service';
import { UploadModule } from '../upload/upload.module';
@Module({
imports: [UploadModule],
controllers: [GenerateController],
providers: [GenerateService, ReplicateService],
exports: [GenerateService],
imports: [UploadModule],
controllers: [GenerateController],
providers: [GenerateService, ReplicateService],
exports: [GenerateService],
})
export class GenerateModule {}

View file

@ -1,518 +1,488 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
imageGenerations,
images,
models,
type ImageGeneration,
type Image,
} from '../db/schema';
import { imageGenerations, images, models, type ImageGeneration, type Image } from '../db/schema';
import { ReplicateService, GenerationParams } from './replicate.service';
import { StorageService } from '../upload/storage.service';
import { GenerateImageDto } from './dto/generate.dto';
export interface GenerateResponse {
generationId: string;
status: string;
image?: Image;
generationId: string;
status: string;
image?: Image;
}
@Injectable()
export class GenerateService {
private readonly logger = new Logger(GenerateService.name);
private readonly webhookBaseUrl: string;
private readonly logger = new Logger(GenerateService.name);
private readonly webhookBaseUrl: string;
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private readonly replicateService: ReplicateService,
private readonly storageService: StorageService,
private configService: ConfigService,
) {
this.webhookBaseUrl =
this.configService.get<string>('WEBHOOK_BASE_URL') ||
'http://localhost:3003';
}
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private readonly replicateService: ReplicateService,
private readonly storageService: StorageService,
private configService: ConfigService
) {
this.webhookBaseUrl =
this.configService.get<string>('WEBHOOK_BASE_URL') || 'http://localhost:3003';
}
/**
* Generate an image - supports both async (webhook) and sync (polling) modes
*/
async generateImage(
userId: string,
dto: GenerateImageDto,
): Promise<GenerateResponse> {
try {
// Get model info
const modelResult = await this.db
.select()
.from(models)
.where(eq(models.id, dto.modelId))
.limit(1);
/**
* Generate an image - supports both async (webhook) and sync (polling) modes
*/
async generateImage(userId: string, dto: GenerateImageDto): Promise<GenerateResponse> {
try {
// Get model info
const modelResult = await this.db
.select()
.from(models)
.where(eq(models.id, dto.modelId))
.limit(1);
if (modelResult.length === 0) {
throw new NotFoundException(`Model with id ${dto.modelId} not found`);
}
if (modelResult.length === 0) {
throw new NotFoundException(`Model with id ${dto.modelId} not found`);
}
const model = modelResult[0];
const model = modelResult[0];
// Create generation record
const generationResult = await this.db
.insert(imageGenerations)
.values({
userId,
modelId: dto.modelId,
prompt: dto.prompt,
negativePrompt: dto.negativePrompt,
model: model.name,
style: dto.style,
width: dto.width || model.defaultWidth || 1024,
height: dto.height || model.defaultHeight || 1024,
steps: dto.steps || model.defaultSteps || 25,
guidanceScale: dto.guidanceScale || model.defaultGuidanceScale || 7.5,
seed: dto.seed,
sourceImageUrl: dto.sourceImageUrl,
generationStrength: dto.generationStrength,
status: 'pending',
})
.returning();
// Create generation record
const generationResult = await this.db
.insert(imageGenerations)
.values({
userId,
modelId: dto.modelId,
prompt: dto.prompt,
negativePrompt: dto.negativePrompt,
model: model.name,
style: dto.style,
width: dto.width || model.defaultWidth || 1024,
height: dto.height || model.defaultHeight || 1024,
steps: dto.steps || model.defaultSteps || 25,
guidanceScale: dto.guidanceScale || model.defaultGuidanceScale || 7.5,
seed: dto.seed,
sourceImageUrl: dto.sourceImageUrl,
generationStrength: dto.generationStrength,
status: 'pending',
})
.returning();
const generation = generationResult[0];
const generation = generationResult[0];
// Build generation params
const generationParams: GenerationParams = {
prompt: dto.prompt,
negativePrompt: dto.negativePrompt,
modelId: model.replicateId,
modelVersion: dto.modelVersion || model.version,
width: dto.width || model.defaultWidth || 1024,
height: dto.height || model.defaultHeight || 1024,
steps: dto.steps || model.defaultSteps || 25,
guidanceScale: dto.guidanceScale || model.defaultGuidanceScale || 7.5,
seed: dto.seed,
sourceImageUrl: dto.sourceImageUrl,
strength: dto.generationStrength,
style: dto.style,
};
// Build generation params
const generationParams: GenerationParams = {
prompt: dto.prompt,
negativePrompt: dto.negativePrompt,
modelId: model.replicateId,
modelVersion: dto.modelVersion || model.version,
width: dto.width || model.defaultWidth || 1024,
height: dto.height || model.defaultHeight || 1024,
steps: dto.steps || model.defaultSteps || 25,
guidanceScale: dto.guidanceScale || model.defaultGuidanceScale || 7.5,
seed: dto.seed,
sourceImageUrl: dto.sourceImageUrl,
strength: dto.generationStrength,
style: dto.style,
};
// If waitForResult is true, use synchronous generation with polling
if (dto.waitForResult) {
return this.generateSync(generation, generationParams);
}
// If waitForResult is true, use synchronous generation with polling
if (dto.waitForResult) {
return this.generateSync(generation, generationParams);
}
// Otherwise use async generation with webhook
return this.generateAsync(generation, model, generationParams);
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error('Error generating image', error);
throw error;
}
}
// Otherwise use async generation with webhook
return this.generateAsync(generation, model, generationParams);
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error('Error generating image', error);
throw error;
}
}
/**
* Synchronous generation - polls until complete
*/
private async generateSync(
generation: ImageGeneration,
params: GenerationParams,
): Promise<GenerateResponse> {
try {
// Update status to processing
await this.db
.update(imageGenerations)
.set({ status: 'processing' })
.where(eq(imageGenerations.id, generation.id));
/**
* Synchronous generation - polls until complete
*/
private async generateSync(
generation: ImageGeneration,
params: GenerationParams
): Promise<GenerateResponse> {
try {
// Update status to processing
await this.db
.update(imageGenerations)
.set({ status: 'processing' })
.where(eq(imageGenerations.id, generation.id));
// Process generation with polling
const result = await this.replicateService.processGeneration(params);
// Process generation with polling
const result = await this.replicateService.processGeneration(params);
if (!result.success || !result.outputUrl) {
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: result.error || 'Generation failed',
})
.where(eq(imageGenerations.id, generation.id));
if (!result.success || !result.outputUrl) {
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: result.error || 'Generation failed',
})
.where(eq(imageGenerations.id, generation.id));
return {
generationId: generation.id,
status: 'failed',
};
}
return {
generationId: generation.id,
status: 'failed',
};
}
// Download and upload to storage
const { storagePath, publicUrl } = await this.storageService.uploadFromUrl(
result.outputUrl,
generation.userId,
`generated-${generation.id}.${result.format || 'png'}`,
);
// Download and upload to storage
const { storagePath, publicUrl } = await this.storageService.uploadFromUrl(
result.outputUrl,
generation.userId,
`generated-${generation.id}.${result.format || 'png'}`
);
// Create image record
const imageResult = await this.db
.insert(images)
.values({
userId: generation.userId,
generationId: generation.id,
prompt: generation.prompt,
negativePrompt: generation.negativePrompt,
model: generation.model,
style: generation.style,
storagePath,
publicUrl,
filename: `generated-${generation.id}.${result.format || 'png'}`,
width: result.width || generation.width,
height: result.height || generation.height,
format: result.format || 'png',
})
.returning();
// Create image record
const imageResult = await this.db
.insert(images)
.values({
userId: generation.userId,
generationId: generation.id,
prompt: generation.prompt,
negativePrompt: generation.negativePrompt,
model: generation.model,
style: generation.style,
storagePath,
publicUrl,
filename: `generated-${generation.id}.${result.format || 'png'}`,
width: result.width || generation.width,
height: result.height || generation.height,
format: result.format || 'png',
})
.returning();
// Update generation as completed
await this.db
.update(imageGenerations)
.set({
status: 'completed',
generationTimeSeconds: result.generationTimeSeconds,
completedAt: new Date(),
})
.where(eq(imageGenerations.id, generation.id));
// Update generation as completed
await this.db
.update(imageGenerations)
.set({
status: 'completed',
generationTimeSeconds: result.generationTimeSeconds,
completedAt: new Date(),
})
.where(eq(imageGenerations.id, generation.id));
return {
generationId: generation.id,
status: 'completed',
image: imageResult[0],
};
} catch (error) {
this.logger.error(`Error in sync generation for ${generation.id}`, error);
return {
generationId: generation.id,
status: 'completed',
image: imageResult[0],
};
} catch (error) {
this.logger.error(`Error in sync generation for ${generation.id}`, error);
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
})
.where(eq(imageGenerations.id, generation.id));
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
})
.where(eq(imageGenerations.id, generation.id));
return {
generationId: generation.id,
status: 'failed',
};
}
}
return {
generationId: generation.id,
status: 'failed',
};
}
}
/**
* Async generation - uses webhook for completion
*/
private async generateAsync(
generation: ImageGeneration,
model: any,
params: GenerationParams,
): Promise<GenerateResponse> {
try {
const webhookUrl = `${this.webhookBaseUrl}/api/generate/webhook`;
/**
* Async generation - uses webhook for completion
*/
private async generateAsync(
generation: ImageGeneration,
model: any,
params: GenerationParams
): Promise<GenerateResponse> {
try {
const webhookUrl = `${this.webhookBaseUrl}/api/generate/webhook`;
const prediction = await this.replicateService.createPrediction(
model.replicateId,
params.modelVersion || model.version || '',
params,
webhookUrl,
);
const prediction = await this.replicateService.createPrediction(
model.replicateId,
params.modelVersion || model.version || '',
params,
webhookUrl
);
// Update generation with prediction ID
await this.db
.update(imageGenerations)
.set({
replicatePredictionId: prediction.id,
status: 'processing',
})
.where(eq(imageGenerations.id, generation.id));
// Update generation with prediction ID
await this.db
.update(imageGenerations)
.set({
replicatePredictionId: prediction.id,
status: 'processing',
})
.where(eq(imageGenerations.id, generation.id));
return {
generationId: generation.id,
status: 'processing',
};
} catch (error) {
// Update generation as failed
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
})
.where(eq(imageGenerations.id, generation.id));
return {
generationId: generation.id,
status: 'processing',
};
} catch (error) {
// Update generation as failed
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
})
.where(eq(imageGenerations.id, generation.id));
throw error;
}
}
throw error;
}
}
async checkStatus(
generationId: string,
userId: string,
): Promise<ImageGeneration & { image?: Image }> {
try {
const result = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.id, generationId))
.limit(1);
async checkStatus(
generationId: string,
userId: string
): Promise<ImageGeneration & { image?: Image }> {
try {
const result = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.id, generationId))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Generation with id ${generationId} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Generation with id ${generationId} not found`);
}
const generation = result[0];
const generation = result[0];
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// If still processing, check Replicate status
if (
generation.status === 'processing' &&
generation.replicatePredictionId
) {
const prediction = await this.replicateService.getPrediction(
generation.replicatePredictionId,
);
// If still processing, check Replicate status
if (generation.status === 'processing' && generation.replicatePredictionId) {
const prediction = await this.replicateService.getPrediction(
generation.replicatePredictionId
);
if (prediction.status === 'succeeded' && prediction.output) {
// Process the completed generation
await this.processCompletedGeneration(generation, prediction.output);
if (prediction.status === 'succeeded' && prediction.output) {
// Process the completed generation
await this.processCompletedGeneration(generation, prediction.output);
// Refetch the updated generation
const updatedResult = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.id, generationId))
.limit(1);
// Refetch the updated generation
const updatedResult = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.id, generationId))
.limit(1);
const updated = updatedResult[0];
const updated = updatedResult[0];
// Get the created image
const imageResult = await this.db
.select()
.from(images)
.where(eq(images.generationId, generationId))
.limit(1);
// Get the created image
const imageResult = await this.db
.select()
.from(images)
.where(eq(images.generationId, generationId))
.limit(1);
return {
...updated,
image: imageResult[0],
};
} else if (prediction.status === 'failed') {
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: prediction.error || 'Generation failed',
})
.where(eq(imageGenerations.id, generationId));
return {
...updated,
image: imageResult[0],
};
} else if (prediction.status === 'failed') {
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: prediction.error || 'Generation failed',
})
.where(eq(imageGenerations.id, generationId));
return {
...generation,
status: 'failed',
errorMessage: prediction.error || 'Generation failed',
};
}
}
return {
...generation,
status: 'failed',
errorMessage: prediction.error || 'Generation failed',
};
}
}
// Get associated image if completed
if (generation.status === 'completed') {
const imageResult = await this.db
.select()
.from(images)
.where(eq(images.generationId, generationId))
.limit(1);
// Get associated image if completed
if (generation.status === 'completed') {
const imageResult = await this.db
.select()
.from(images)
.where(eq(images.generationId, generationId))
.limit(1);
return {
...generation,
image: imageResult[0],
};
}
return {
...generation,
image: imageResult[0],
};
}
return generation;
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error checking status for generation ${generationId}`, error);
throw error;
}
}
return generation;
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error checking status for generation ${generationId}`, error);
throw error;
}
}
async cancelGeneration(generationId: string, userId: string): Promise<void> {
try {
const result = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.id, generationId))
.limit(1);
async cancelGeneration(generationId: string, userId: string): Promise<void> {
try {
const result = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.id, generationId))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Generation with id ${generationId} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Generation with id ${generationId} not found`);
}
const generation = result[0];
const generation = result[0];
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (generation.status !== 'pending' && generation.status !== 'processing') {
return; // Already completed or failed
}
if (generation.status !== 'pending' && generation.status !== 'processing') {
return; // Already completed or failed
}
// Cancel on Replicate
if (generation.replicatePredictionId) {
try {
await this.replicateService.cancelPrediction(
generation.replicatePredictionId,
);
} catch (error) {
this.logger.warn('Failed to cancel prediction on Replicate', error);
}
}
// Cancel on Replicate
if (generation.replicatePredictionId) {
try {
await this.replicateService.cancelPrediction(generation.replicatePredictionId);
} catch (error) {
this.logger.warn('Failed to cancel prediction on Replicate', error);
}
}
// Update status
await this.db
.update(imageGenerations)
.set({
status: 'cancelled',
errorMessage: 'Cancelled by user',
})
.where(eq(imageGenerations.id, generationId));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error cancelling generation ${generationId}`, error);
throw error;
}
}
// Update status
await this.db
.update(imageGenerations)
.set({
status: 'cancelled',
errorMessage: 'Cancelled by user',
})
.where(eq(imageGenerations.id, generationId));
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error cancelling generation ${generationId}`, error);
throw error;
}
}
async handleWebhook(body: any): Promise<{ received: boolean }> {
try {
const { id, status, output, error, metrics } = body;
async handleWebhook(body: any): Promise<{ received: boolean }> {
try {
const { id, status, output, error, metrics } = body;
if (!id) {
return { received: false };
}
if (!id) {
return { received: false };
}
// Find the generation by prediction ID
const result = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.replicatePredictionId, id))
.limit(1);
// Find the generation by prediction ID
const result = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.replicatePredictionId, id))
.limit(1);
if (result.length === 0) {
this.logger.warn(`No generation found for prediction ${id}`);
return { received: false };
}
if (result.length === 0) {
this.logger.warn(`No generation found for prediction ${id}`);
return { received: false };
}
const generation = result[0];
const generation = result[0];
if (status === 'succeeded' && output) {
await this.processCompletedGeneration(generation, output);
} else if (status === 'failed') {
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error || 'Generation failed',
})
.where(eq(imageGenerations.id, generation.id));
}
if (status === 'succeeded' && output) {
await this.processCompletedGeneration(generation, output);
} else if (status === 'failed') {
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error || 'Generation failed',
})
.where(eq(imageGenerations.id, generation.id));
}
return { received: true };
} catch (error) {
this.logger.error('Error handling webhook', error);
return { received: false };
}
}
return { received: true };
} catch (error) {
this.logger.error('Error handling webhook', error);
return { received: false };
}
}
private async processCompletedGeneration(
generation: ImageGeneration,
output: string[] | string | { url?: string },
): Promise<void> {
try {
// Extract output URL
let imageUrl: string;
if (Array.isArray(output)) {
imageUrl = output[0];
} else if (typeof output === 'string') {
imageUrl = output;
} else if (output && typeof output === 'object' && output.url) {
imageUrl = output.url;
} else {
throw new Error('No output URL from generation');
}
private async processCompletedGeneration(
generation: ImageGeneration,
output: string[] | string | { url?: string }
): Promise<void> {
try {
// Extract output URL
let imageUrl: string;
if (Array.isArray(output)) {
imageUrl = output[0];
} else if (typeof output === 'string') {
imageUrl = output;
} else if (output && typeof output === 'object' && output.url) {
imageUrl = output.url;
} else {
throw new Error('No output URL from generation');
}
// Determine format from URL
let format = 'png';
if (imageUrl.includes('.webp')) format = 'webp';
else if (imageUrl.includes('.jpg') || imageUrl.includes('.jpeg')) format = 'jpeg';
else if (imageUrl.includes('.svg')) format = 'svg';
// Determine format from URL
let format = 'png';
if (imageUrl.includes('.webp')) format = 'webp';
else if (imageUrl.includes('.jpg') || imageUrl.includes('.jpeg')) format = 'jpeg';
else if (imageUrl.includes('.svg')) format = 'svg';
// Download and upload to storage
const { storagePath, publicUrl } = await this.storageService.uploadFromUrl(
imageUrl,
generation.userId,
`generated-${generation.id}.${format}`,
);
// Download and upload to storage
const { storagePath, publicUrl } = await this.storageService.uploadFromUrl(
imageUrl,
generation.userId,
`generated-${generation.id}.${format}`
);
// Create image record
await this.db.insert(images).values({
userId: generation.userId,
generationId: generation.id,
prompt: generation.prompt,
negativePrompt: generation.negativePrompt,
model: generation.model,
style: generation.style,
storagePath,
publicUrl,
filename: `generated-${generation.id}.${format}`,
width: generation.width,
height: generation.height,
format,
});
// Create image record
await this.db.insert(images).values({
userId: generation.userId,
generationId: generation.id,
prompt: generation.prompt,
negativePrompt: generation.negativePrompt,
model: generation.model,
style: generation.style,
storagePath,
publicUrl,
filename: `generated-${generation.id}.${format}`,
width: generation.width,
height: generation.height,
format,
});
// Update generation as completed
await this.db
.update(imageGenerations)
.set({
status: 'completed',
completedAt: new Date(),
})
.where(eq(imageGenerations.id, generation.id));
} catch (error) {
this.logger.error(
`Error processing completed generation ${generation.id}`,
error,
);
// Update generation as completed
await this.db
.update(imageGenerations)
.set({
status: 'completed',
completedAt: new Date(),
})
.where(eq(imageGenerations.id, generation.id));
} catch (error) {
this.logger.error(`Error processing completed generation ${generation.id}`, error);
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Processing failed',
})
.where(eq(imageGenerations.id, generation.id));
}
}
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Processing failed',
})
.where(eq(imageGenerations.id, generation.id));
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,12 @@ import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'picture-backend',
};
}
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'picture-backend',
};
}
}

View file

@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -1,44 +1,38 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsArray,
} from 'class-validator';
import { IsString, IsOptional, IsBoolean, IsNumber, IsArray } from 'class-validator';
import { Transform, Type } from 'class-transformer';
export class GetImagesQueryDto {
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
archived?: boolean = false;
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
archived?: boolean = false;
@IsOptional()
tagIds?: string | string[];
@IsOptional()
tagIds?: string | string[];
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
favoritesOnly?: boolean = false;
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
favoritesOnly?: boolean = false;
}
export class ToggleFavoriteDto {
@IsBoolean()
isFavorite: boolean;
@IsBoolean()
isFavorite: boolean;
}
export class BatchImageIdsDto {
@IsArray()
@IsString({ each: true })
imageIds: string[];
@IsArray()
@IsString({ each: true })
imageIds: string[];
}

View file

@ -1,154 +1,112 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Query,
Body,
UseGuards,
Controller,
Get,
Post,
Patch,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import { ImageService } from './image.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { GetImagesQueryDto, ToggleFavoriteDto, BatchImageIdsDto } from './dto/image.dto';
@Controller('images')
@UseGuards(JwtAuthGuard)
export class ImageController {
constructor(private readonly imageService: ImageService) {}
constructor(private readonly imageService: ImageService) {}
@Get()
async getImages(
@CurrentUser() user: CurrentUserData,
@Query() query: GetImagesQueryDto,
) {
return this.imageService.getImages(user.userId, query);
}
@Get()
async getImages(@CurrentUser() user: CurrentUserData, @Query() query: GetImagesQueryDto) {
return this.imageService.getImages(user.userId, query);
}
@Get(':id')
async getImageById(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.getImageById(id, user.userId);
}
@Get(':id')
async getImageById(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.imageService.getImageById(id, user.userId);
}
@Patch(':id/archive')
async archiveImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.archiveImage(id, user.userId);
}
@Patch(':id/archive')
async archiveImage(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.imageService.archiveImage(id, user.userId);
}
@Patch(':id/unarchive')
async unarchiveImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.unarchiveImage(id, user.userId);
}
@Patch(':id/unarchive')
async unarchiveImage(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.imageService.unarchiveImage(id, user.userId);
}
@Delete(':id')
async deleteImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.deleteImage(id, user.userId);
}
@Delete(':id')
async deleteImage(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.imageService.deleteImage(id, user.userId);
}
@Patch(':id/publish')
async publishImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.publishImage(id, user.userId);
}
@Patch(':id/publish')
async publishImage(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.imageService.publishImage(id, user.userId);
}
@Patch(':id/unpublish')
async unpublishImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.unpublishImage(id, user.userId);
}
@Patch(':id/unpublish')
async unpublishImage(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.imageService.unpublishImage(id, user.userId);
}
@Patch(':id/favorite')
async toggleFavorite(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: ToggleFavoriteDto,
) {
return this.imageService.toggleFavorite(id, user.userId, dto.isFavorite);
}
@Patch(':id/favorite')
async toggleFavorite(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: ToggleFavoriteDto
) {
return this.imageService.toggleFavorite(id, user.userId, dto.isFavorite);
}
@Get('archived/count')
async getArchivedCount(@CurrentUser() user: CurrentUserData) {
return this.imageService.getArchivedCount(user.userId);
}
@Get('archived/count')
async getArchivedCount(@CurrentUser() user: CurrentUserData) {
return this.imageService.getArchivedCount(user.userId);
}
@Post('batch/archive')
async batchArchive(
@CurrentUser() user: CurrentUserData,
@Body() dto: BatchImageIdsDto,
) {
return this.imageService.batchArchiveImages(dto.imageIds, user.userId);
}
@Post('batch/archive')
async batchArchive(@CurrentUser() user: CurrentUserData, @Body() dto: BatchImageIdsDto) {
return this.imageService.batchArchiveImages(dto.imageIds, user.userId);
}
@Post('batch/restore')
async batchRestore(
@CurrentUser() user: CurrentUserData,
@Body() dto: BatchImageIdsDto,
) {
return this.imageService.batchRestoreImages(dto.imageIds, user.userId);
}
@Post('batch/restore')
async batchRestore(@CurrentUser() user: CurrentUserData, @Body() dto: BatchImageIdsDto) {
return this.imageService.batchRestoreImages(dto.imageIds, user.userId);
}
@Post('batch/delete')
async batchDelete(
@CurrentUser() user: CurrentUserData,
@Body() dto: BatchImageIdsDto,
) {
return this.imageService.batchDeleteImages(dto.imageIds, user.userId);
}
@Post('batch/delete')
async batchDelete(@CurrentUser() user: CurrentUserData, @Body() dto: BatchImageIdsDto) {
return this.imageService.batchDeleteImages(dto.imageIds, user.userId);
}
// ==================== LIKES ====================
// ==================== LIKES ====================
@Post(':id/like')
async likeImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.likeImage(id, user.userId);
}
@Post(':id/like')
async likeImage(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.imageService.likeImage(id, user.userId);
}
@Delete(':id/like')
async unlikeImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.unlikeImage(id, user.userId);
}
@Delete(':id/like')
async unlikeImage(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.imageService.unlikeImage(id, user.userId);
}
@Get(':id/likes')
async getLikeStatus(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.getLikeStatus(id, user.userId);
}
@Get(':id/likes')
async getLikeStatus(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.imageService.getLikeStatus(id, user.userId);
}
// ==================== GENERATION DETAILS ====================
// ==================== GENERATION DETAILS ====================
@Get('generation/:generationId')
async getGenerationDetails(
@CurrentUser() user: CurrentUserData,
@Param('generationId') generationId: string,
) {
return this.imageService.getGenerationDetails(generationId, user.userId);
}
@Get('generation/:generationId')
async getGenerationDetails(
@CurrentUser() user: CurrentUserData,
@Param('generationId') generationId: string
) {
return this.imageService.getGenerationDetails(generationId, user.userId);
}
}

View file

@ -3,8 +3,8 @@ import { ImageController } from './image.controller';
import { ImageService } from './image.service';
@Module({
controllers: [ImageController],
providers: [ImageService],
exports: [ImageService],
controllers: [ImageController],
providers: [ImageService],
exports: [ImageService],
})
export class ImageModule {}

File diff suppressed because it is too large Load diff

View file

@ -3,36 +3,36 @@ import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
app.enableCors({
origin: [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:8081',
'exp://localhost:8081',
'http://localhost:3001', // Mana Core Auth
],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable CORS for mobile and web apps
app.enableCors({
origin: [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:8081',
'exp://localhost:8081',
'http://localhost:3001', // Mana Core Auth
],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3003;
await app.listen(port);
console.log(`Picture backend running on http://localhost:${port}`);
const port = process.env.PORT || 3003;
await app.listen(port);
console.log(`Picture backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -5,15 +5,15 @@ import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@Controller('models')
@UseGuards(JwtAuthGuard)
export class ModelController {
constructor(private readonly modelService: ModelService) {}
constructor(private readonly modelService: ModelService) {}
@Get()
async getActiveModels() {
return this.modelService.getActiveModels();
}
@Get()
async getActiveModels() {
return this.modelService.getActiveModels();
}
@Get(':id')
async getModelById(@Param('id') id: string) {
return this.modelService.getModelById(id);
}
@Get(':id')
async getModelById(@Param('id') id: string) {
return this.modelService.getModelById(id);
}
}

View file

@ -3,8 +3,8 @@ import { ModelController } from './model.controller';
import { ModelService } from './model.service';
@Module({
controllers: [ModelController],
providers: [ModelService],
exports: [ModelService],
controllers: [ModelController],
providers: [ModelService],
exports: [ModelService],
})
export class ModelModule {}

View file

@ -6,44 +6,40 @@ import { models, type Model } from '../db/schema';
@Injectable()
export class ModelService {
private readonly logger = new Logger(ModelService.name);
private readonly logger = new Logger(ModelService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getActiveModels(): Promise<Model[]> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.isActive, true))
.orderBy(desc(models.isDefault), models.sortOrder);
async getActiveModels(): Promise<Model[]> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.isActive, true))
.orderBy(desc(models.isDefault), models.sortOrder);
return result;
} catch (error) {
this.logger.error('Error fetching active models', error);
throw error;
}
}
return result;
} catch (error) {
this.logger.error('Error fetching active models', error);
throw error;
}
}
async getModelById(id: string): Promise<Model> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.id, id))
.limit(1);
async getModelById(id: string): Promise<Model> {
try {
const result = await this.db.select().from(models).where(eq(models.id, id)).limit(1);
if (result.length === 0) {
throw new NotFoundException(`Model with id ${id} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Model with id ${id} not found`);
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error fetching model ${id}`, error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error fetching model ${id}`, error);
throw error;
}
}
}

View file

@ -1,30 +1,30 @@
import { IsString, IsOptional, MaxLength, MinLength } from 'class-validator';
export class UpdateProfileDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(50)
username?: string;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(50)
username?: string;
@IsOptional()
@IsString()
@MaxLength(500)
avatarUrl?: string;
@IsOptional()
@IsString()
@MaxLength(500)
avatarUrl?: string;
}
export interface ProfileResponse {
id: string;
username: string | null;
email: string;
avatarUrl: string | null;
createdAt: Date;
updatedAt: Date;
id: string;
username: string | null;
email: string;
avatarUrl: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface UserStatsResponse {
totalImages: number;
favoriteImages: number;
archivedImages: number;
publicImages: number;
totalImages: number;
favoriteImages: number;
archivedImages: number;
publicImages: number;
}

View file

@ -1,43 +1,30 @@
import {
Controller,
Get,
Patch,
Body,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { ProfileService } from './profile.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { UpdateProfileDto, ProfileResponse, UserStatsResponse } from './dto/profile.dto';
@Controller('profiles')
@UseGuards(JwtAuthGuard)
export class ProfileController {
constructor(private readonly profileService: ProfileService) {}
constructor(private readonly profileService: ProfileService) {}
@Get('me')
async getMyProfile(
@CurrentUser() user: CurrentUserData,
): Promise<ProfileResponse> {
// Get or create profile (ensures profile exists)
return this.profileService.getOrCreateProfile(user.userId, user.email);
}
@Get('me')
async getMyProfile(@CurrentUser() user: CurrentUserData): Promise<ProfileResponse> {
// Get or create profile (ensures profile exists)
return this.profileService.getOrCreateProfile(user.userId, user.email);
}
@Patch('me')
async updateMyProfile(
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateProfileDto,
): Promise<ProfileResponse> {
return this.profileService.updateProfile(user.userId, dto);
}
@Patch('me')
async updateMyProfile(
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateProfileDto
): Promise<ProfileResponse> {
return this.profileService.updateProfile(user.userId, dto);
}
@Get('stats')
async getMyStats(
@CurrentUser() user: CurrentUserData,
): Promise<UserStatsResponse> {
return this.profileService.getUserStats(user.userId);
}
@Get('stats')
async getMyStats(@CurrentUser() user: CurrentUserData): Promise<UserStatsResponse> {
return this.profileService.getUserStats(user.userId);
}
}

View file

@ -3,8 +3,8 @@ import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service';
@Module({
controllers: [ProfileController],
providers: [ProfileService],
exports: [ProfileService],
controllers: [ProfileController],
providers: [ProfileService],
exports: [ProfileService],
})
export class ProfileModule {}

View file

@ -1,9 +1,4 @@
import {
Injectable,
Inject,
NotFoundException,
Logger,
} from '@nestjs/common';
import { Injectable, Inject, NotFoundException, Logger } from '@nestjs/common';
import { eq, and, isNull, isNotNull, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
@ -12,144 +7,129 @@ import { UpdateProfileDto, ProfileResponse, UserStatsResponse } from './dto/prof
@Injectable()
export class ProfileService {
private readonly logger = new Logger(ProfileService.name);
private readonly logger = new Logger(ProfileService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getProfile(userId: string): Promise<ProfileResponse> {
try {
const result = await this.db
.select()
.from(profiles)
.where(eq(profiles.id, userId))
.limit(1);
async getProfile(userId: string): Promise<ProfileResponse> {
try {
const result = await this.db.select().from(profiles).where(eq(profiles.id, userId)).limit(1);
if (result.length === 0) {
throw new NotFoundException('Profile not found');
}
if (result.length === 0) {
throw new NotFoundException('Profile not found');
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error fetching profile for user ${userId}`, error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error fetching profile for user ${userId}`, error);
throw error;
}
}
async getOrCreateProfile(userId: string, email: string): Promise<ProfileResponse> {
try {
// Try to get existing profile
const existing = await this.db
.select()
.from(profiles)
.where(eq(profiles.id, userId))
.limit(1);
async getOrCreateProfile(userId: string, email: string): Promise<ProfileResponse> {
try {
// Try to get existing profile
const existing = await this.db
.select()
.from(profiles)
.where(eq(profiles.id, userId))
.limit(1);
if (existing.length > 0) {
return existing[0];
}
if (existing.length > 0) {
return existing[0];
}
// Create new profile
const newProfile = await this.db
.insert(profiles)
.values({
id: userId,
email,
username: null,
})
.returning();
// Create new profile
const newProfile = await this.db
.insert(profiles)
.values({
id: userId,
email,
username: null,
})
.returning();
return newProfile[0];
} catch (error) {
this.logger.error(`Error getting/creating profile for user ${userId}`, error);
throw error;
}
}
return newProfile[0];
} catch (error) {
this.logger.error(`Error getting/creating profile for user ${userId}`, error);
throw error;
}
}
async updateProfile(
userId: string,
dto: UpdateProfileDto,
): Promise<ProfileResponse> {
try {
// Check if profile exists
const existing = await this.db
.select()
.from(profiles)
.where(eq(profiles.id, userId))
.limit(1);
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponse> {
try {
// Check if profile exists
const existing = await this.db
.select()
.from(profiles)
.where(eq(profiles.id, userId))
.limit(1);
if (existing.length === 0) {
throw new NotFoundException('Profile not found');
}
if (existing.length === 0) {
throw new NotFoundException('Profile not found');
}
const result = await this.db
.update(profiles)
.set({
...dto,
updatedAt: new Date(),
})
.where(eq(profiles.id, userId))
.returning();
const result = await this.db
.update(profiles)
.set({
...dto,
updatedAt: new Date(),
})
.where(eq(profiles.id, userId))
.returning();
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error updating profile for user ${userId}`, error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error updating profile for user ${userId}`, error);
throw error;
}
}
async getUserStats(userId: string): Promise<UserStatsResponse> {
try {
// Get total images (non-archived)
const totalResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(images)
.where(and(eq(images.userId, userId), isNull(images.archivedAt)));
async getUserStats(userId: string): Promise<UserStatsResponse> {
try {
// Get total images (non-archived)
const totalResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(images)
.where(and(eq(images.userId, userId), isNull(images.archivedAt)));
// Get favorite images
const favoriteResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(images)
.where(
and(
eq(images.userId, userId),
eq(images.isFavorite, true),
isNull(images.archivedAt),
),
);
// Get favorite images
const favoriteResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(images)
.where(
and(eq(images.userId, userId), eq(images.isFavorite, true), isNull(images.archivedAt))
);
// Get archived images
const archivedResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(images)
.where(and(eq(images.userId, userId), isNotNull(images.archivedAt)));
// Get archived images
const archivedResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(images)
.where(and(eq(images.userId, userId), isNotNull(images.archivedAt)));
// Get public images
const publicResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(images)
.where(
and(
eq(images.userId, userId),
eq(images.isPublic, true),
isNull(images.archivedAt),
),
);
// Get public images
const publicResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(images)
.where(
and(eq(images.userId, userId), eq(images.isPublic, true), isNull(images.archivedAt))
);
return {
totalImages: Number(totalResult[0]?.count || 0),
favoriteImages: Number(favoriteResult[0]?.count || 0),
archivedImages: Number(archivedResult[0]?.count || 0),
publicImages: Number(publicResult[0]?.count || 0),
};
} catch (error) {
this.logger.error(`Error fetching stats for user ${userId}`, error);
throw error;
}
}
return {
totalImages: Number(totalResult[0]?.count || 0),
favoriteImages: Number(favoriteResult[0]?.count || 0),
archivedImages: Number(archivedResult[0]?.count || 0),
publicImages: Number(publicResult[0]?.count || 0),
};
} catch (error) {
this.logger.error(`Error fetching stats for user ${userId}`, error);
throw error;
}
}
}

View file

@ -1,20 +1,20 @@
import { IsString, IsOptional } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
@IsString()
name: string;
@IsString()
@IsOptional()
color?: string;
@IsString()
@IsOptional()
color?: string;
}
export class UpdateTagDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
color?: string;
@IsString()
@IsOptional()
color?: string;
}

View file

@ -1,13 +1,4 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { TagService } from './tag.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CreateTagDto, UpdateTagDto } from './dto/tag.dto';
@ -15,46 +6,40 @@ import { CreateTagDto, UpdateTagDto } from './dto/tag.dto';
@Controller('tags')
@UseGuards(JwtAuthGuard)
export class TagController {
constructor(private readonly tagService: TagService) {}
constructor(private readonly tagService: TagService) {}
@Get()
async getAllTags() {
return this.tagService.getAllTags();
}
@Get()
async getAllTags() {
return this.tagService.getAllTags();
}
@Post()
async createTag(@Body() dto: CreateTagDto) {
return this.tagService.createTag(dto);
}
@Post()
async createTag(@Body() dto: CreateTagDto) {
return this.tagService.createTag(dto);
}
@Patch(':id')
async updateTag(@Param('id') id: string, @Body() dto: UpdateTagDto) {
return this.tagService.updateTag(id, dto);
}
@Patch(':id')
async updateTag(@Param('id') id: string, @Body() dto: UpdateTagDto) {
return this.tagService.updateTag(id, dto);
}
@Delete(':id')
async deleteTag(@Param('id') id: string) {
return this.tagService.deleteTag(id);
}
@Delete(':id')
async deleteTag(@Param('id') id: string) {
return this.tagService.deleteTag(id);
}
@Get('image/:imageId')
async getImageTags(@Param('imageId') imageId: string) {
return this.tagService.getImageTags(imageId);
}
@Get('image/:imageId')
async getImageTags(@Param('imageId') imageId: string) {
return this.tagService.getImageTags(imageId);
}
@Post('image/:imageId/:tagId')
async addTagToImage(
@Param('imageId') imageId: string,
@Param('tagId') tagId: string,
) {
return this.tagService.addTagToImage(imageId, tagId);
}
@Post('image/:imageId/:tagId')
async addTagToImage(@Param('imageId') imageId: string, @Param('tagId') tagId: string) {
return this.tagService.addTagToImage(imageId, tagId);
}
@Delete('image/:imageId/:tagId')
async removeTagFromImage(
@Param('imageId') imageId: string,
@Param('tagId') tagId: string,
) {
return this.tagService.removeTagFromImage(imageId, tagId);
}
@Delete('image/:imageId/:tagId')
async removeTagFromImage(@Param('imageId') imageId: string, @Param('tagId') tagId: string) {
return this.tagService.removeTagFromImage(imageId, tagId);
}
}

View file

@ -3,8 +3,8 @@ import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
providers: [TagService],
exports: [TagService],
controllers: [TagController],
providers: [TagService],
exports: [TagService],
})
export class TagModule {}

View file

@ -7,151 +7,135 @@ import { CreateTagDto, UpdateTagDto } from './dto/tag.dto';
@Injectable()
export class TagService {
private readonly logger = new Logger(TagService.name);
private readonly logger = new Logger(TagService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getAllTags(): Promise<Tag[]> {
try {
const result = await this.db
.select()
.from(tags)
.orderBy(tags.name);
async getAllTags(): Promise<Tag[]> {
try {
const result = await this.db.select().from(tags).orderBy(tags.name);
return result;
} catch (error) {
this.logger.error('Error fetching tags', error);
throw error;
}
}
return result;
} catch (error) {
this.logger.error('Error fetching tags', error);
throw error;
}
}
async createTag(dto: CreateTagDto): Promise<Tag> {
try {
const result = await this.db
.insert(tags)
.values({
name: dto.name,
color: dto.color,
})
.returning();
async createTag(dto: CreateTagDto): Promise<Tag> {
try {
const result = await this.db
.insert(tags)
.values({
name: dto.name,
color: dto.color,
})
.returning();
return result[0];
} catch (error) {
this.logger.error('Error creating tag', error);
throw error;
}
}
return result[0];
} catch (error) {
this.logger.error('Error creating tag', error);
throw error;
}
}
async updateTag(id: string, dto: UpdateTagDto): Promise<Tag> {
try {
const result = await this.db
.update(tags)
.set({
...(dto.name && { name: dto.name }),
...(dto.color !== undefined && { color: dto.color }),
})
.where(eq(tags.id, id))
.returning();
async updateTag(id: string, dto: UpdateTagDto): Promise<Tag> {
try {
const result = await this.db
.update(tags)
.set({
...(dto.name && { name: dto.name }),
...(dto.color !== undefined && { color: dto.color }),
})
.where(eq(tags.id, id))
.returning();
if (result.length === 0) {
throw new NotFoundException(`Tag with id ${id} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Tag with id ${id} not found`);
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error updating tag ${id}`, error);
throw error;
}
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error updating tag ${id}`, error);
throw error;
}
}
async deleteTag(id: string): Promise<void> {
try {
// Delete image-tag relations first
await this.db.delete(imageTags).where(eq(imageTags.tagId, id));
async deleteTag(id: string): Promise<void> {
try {
// Delete image-tag relations first
await this.db.delete(imageTags).where(eq(imageTags.tagId, id));
// Delete the tag
const result = await this.db
.delete(tags)
.where(eq(tags.id, id))
.returning();
// Delete the tag
const result = await this.db.delete(tags).where(eq(tags.id, id)).returning();
if (result.length === 0) {
throw new NotFoundException(`Tag with id ${id} not found`);
}
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error deleting tag ${id}`, error);
throw error;
}
}
if (result.length === 0) {
throw new NotFoundException(`Tag with id ${id} not found`);
}
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error deleting tag ${id}`, error);
throw error;
}
}
async getImageTags(imageId: string): Promise<Tag[]> {
try {
const result = await this.db
.select({
id: tags.id,
name: tags.name,
color: tags.color,
createdAt: tags.createdAt,
})
.from(imageTags)
.innerJoin(tags, eq(imageTags.tagId, tags.id))
.where(eq(imageTags.imageId, imageId))
.orderBy(tags.name);
async getImageTags(imageId: string): Promise<Tag[]> {
try {
const result = await this.db
.select({
id: tags.id,
name: tags.name,
color: tags.color,
createdAt: tags.createdAt,
})
.from(imageTags)
.innerJoin(tags, eq(imageTags.tagId, tags.id))
.where(eq(imageTags.imageId, imageId))
.orderBy(tags.name);
return result;
} catch (error) {
this.logger.error(`Error fetching tags for image ${imageId}`, error);
throw error;
}
}
return result;
} catch (error) {
this.logger.error(`Error fetching tags for image ${imageId}`, error);
throw error;
}
}
async addTagToImage(imageId: string, tagId: string): Promise<void> {
try {
// Check if relation already exists
const existing = await this.db
.select()
.from(imageTags)
.where(
and(eq(imageTags.imageId, imageId), eq(imageTags.tagId, tagId)),
)
.limit(1);
async addTagToImage(imageId: string, tagId: string): Promise<void> {
try {
// Check if relation already exists
const existing = await this.db
.select()
.from(imageTags)
.where(and(eq(imageTags.imageId, imageId), eq(imageTags.tagId, tagId)))
.limit(1);
if (existing.length > 0) {
return; // Already exists
}
if (existing.length > 0) {
return; // Already exists
}
await this.db.insert(imageTags).values({
imageId,
tagId,
});
} catch (error) {
this.logger.error(
`Error adding tag ${tagId} to image ${imageId}`,
error,
);
throw error;
}
}
await this.db.insert(imageTags).values({
imageId,
tagId,
});
} catch (error) {
this.logger.error(`Error adding tag ${tagId} to image ${imageId}`, error);
throw error;
}
}
async removeTagFromImage(imageId: string, tagId: string): Promise<void> {
try {
await this.db
.delete(imageTags)
.where(
and(eq(imageTags.imageId, imageId), eq(imageTags.tagId, tagId)),
);
} catch (error) {
this.logger.error(
`Error removing tag ${tagId} from image ${imageId}`,
error,
);
throw error;
}
}
async removeTagFromImage(imageId: string, tagId: string): Promise<void> {
try {
await this.db
.delete(imageTags)
.where(and(eq(imageTags.imageId, imageId), eq(imageTags.tagId, tagId)));
} catch (error) {
this.logger.error(`Error removing tag ${tagId} from image ${imageId}`, error);
throw error;
}
}
}

View file

@ -4,123 +4,110 @@ import { createClient, SupabaseClient } from '@supabase/supabase-js';
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private supabase: SupabaseClient | null = null;
private readonly bucket = 'user-uploads';
private readonly logger = new Logger(StorageService.name);
private supabase: SupabaseClient | null = null;
private readonly bucket = 'user-uploads';
constructor(private configService: ConfigService) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL');
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
constructor(private configService: ConfigService) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL');
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
if (supabaseUrl && supabaseKey) {
this.supabase = createClient(supabaseUrl, supabaseKey);
} else {
this.logger.warn('Supabase credentials not configured');
}
}
if (supabaseUrl && supabaseKey) {
this.supabase = createClient(supabaseUrl, supabaseKey);
} else {
this.logger.warn('Supabase credentials not configured');
}
}
async uploadFile(
buffer: Buffer,
userId: string,
filename: string,
contentType: string,
): Promise<{ storagePath: string; publicUrl: string }> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
async uploadFile(
buffer: Buffer,
userId: string,
filename: string,
contentType: string
): Promise<{ storagePath: string; publicUrl: string }> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 10);
const ext = filename.split('.').pop() || 'jpg';
const storagePath = `${userId}/${timestamp}-${randomId}.${ext}`;
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 10);
const ext = filename.split('.').pop() || 'jpg';
const storagePath = `${userId}/${timestamp}-${randomId}.${ext}`;
const { error } = await this.supabase.storage
.from(this.bucket)
.upload(storagePath, buffer, {
contentType,
upsert: false,
});
const { error } = await this.supabase.storage.from(this.bucket).upload(storagePath, buffer, {
contentType,
upsert: false,
});
if (error) {
this.logger.error('Error uploading file to storage', error);
throw error;
}
if (error) {
this.logger.error('Error uploading file to storage', error);
throw error;
}
const { data: urlData } = this.supabase.storage
.from(this.bucket)
.getPublicUrl(storagePath);
const { data: urlData } = this.supabase.storage.from(this.bucket).getPublicUrl(storagePath);
return {
storagePath,
publicUrl: urlData.publicUrl,
};
}
return {
storagePath,
publicUrl: urlData.publicUrl,
};
}
async uploadFromUrl(
url: string,
userId: string,
filename: string,
): Promise<{ storagePath: string; publicUrl: string }> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
async uploadFromUrl(
url: string,
userId: string,
filename: string
): Promise<{ storagePath: string; publicUrl: string }> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
// Download the file
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download file from ${url}`);
}
// Download the file
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download file from ${url}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || 'image/jpeg';
const buffer = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || 'image/jpeg';
return this.uploadFile(buffer, userId, filename, contentType);
}
return this.uploadFile(buffer, userId, filename, contentType);
}
async deleteFile(storagePath: string): Promise<void> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
async deleteFile(storagePath: string): Promise<void> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
const { error } = await this.supabase.storage
.from(this.bucket)
.remove([storagePath]);
const { error } = await this.supabase.storage.from(this.bucket).remove([storagePath]);
if (error) {
this.logger.error(`Error deleting file ${storagePath}`, error);
throw error;
}
}
if (error) {
this.logger.error(`Error deleting file ${storagePath}`, error);
throw error;
}
}
async uploadBoardThumbnail(
boardId: string,
dataUrl: string,
): Promise<string> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
async uploadBoardThumbnail(boardId: string, dataUrl: string): Promise<string> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
const filename = `boards/${boardId}/thumbnail-${Date.now()}.png`;
const filename = `boards/${boardId}/thumbnail-${Date.now()}.png`;
const { error } = await this.supabase.storage
.from(this.bucket)
.upload(filename, buffer, {
contentType: 'image/png',
upsert: true,
});
const { error } = await this.supabase.storage.from(this.bucket).upload(filename, buffer, {
contentType: 'image/png',
upsert: true,
});
if (error) {
this.logger.error('Error uploading board thumbnail', error);
throw error;
}
if (error) {
this.logger.error('Error uploading board thumbnail', error);
throw error;
}
const { data: urlData } = this.supabase.storage
.from(this.bucket)
.getPublicUrl(filename);
const { data: urlData } = this.supabase.storage.from(this.bucket).getPublicUrl(filename);
return urlData.publicUrl;
}
return urlData.publicUrl;
}
}

View file

@ -1,21 +1,18 @@
import {
Controller,
Post,
Delete,
Param,
UseGuards,
UseInterceptors,
UploadedFile,
UploadedFiles,
BadRequestException,
Controller,
Post,
Delete,
Param,
UseGuards,
UseInterceptors,
UploadedFile,
UploadedFiles,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { UploadService } from './upload.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@ -23,71 +20,64 @@ const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@Controller('upload')
@UseGuards(JwtAuthGuard)
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
constructor(private readonly uploadService: UploadService) {}
@Post()
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, callback) => {
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
callback(
new BadRequestException(
'Invalid file type. Only JPEG, PNG, and WebP are allowed.',
),
false,
);
return;
}
callback(null, true);
},
}),
)
async uploadImage(
@CurrentUser() user: CurrentUserData,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
@Post()
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, callback) => {
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
callback(
new BadRequestException('Invalid file type. Only JPEG, PNG, and WebP are allowed.'),
false
);
return;
}
callback(null, true);
},
})
)
async uploadImage(
@CurrentUser() user: CurrentUserData,
@UploadedFile() file: Express.Multer.File
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.uploadService.uploadImage(user.userId, file);
}
return this.uploadService.uploadImage(user.userId, file);
}
@Post('multiple')
@UseInterceptors(
FilesInterceptor('files', 10, {
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, callback) => {
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
callback(
new BadRequestException(
'Invalid file type. Only JPEG, PNG, and WebP are allowed.',
),
false,
);
return;
}
callback(null, true);
},
}),
)
async uploadMultiple(
@CurrentUser() user: CurrentUserData,
@UploadedFiles() files: Express.Multer.File[],
) {
if (!files || files.length === 0) {
throw new BadRequestException('No files uploaded');
}
@Post('multiple')
@UseInterceptors(
FilesInterceptor('files', 10, {
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, callback) => {
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
callback(
new BadRequestException('Invalid file type. Only JPEG, PNG, and WebP are allowed.'),
false
);
return;
}
callback(null, true);
},
})
)
async uploadMultiple(
@CurrentUser() user: CurrentUserData,
@UploadedFiles() files: Express.Multer.File[]
) {
if (!files || files.length === 0) {
throw new BadRequestException('No files uploaded');
}
return this.uploadService.uploadMultiple(user.userId, files);
}
return this.uploadService.uploadMultiple(user.userId, files);
}
@Delete(':id')
async deleteUploadedImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.uploadService.deleteUploadedImage(id, user.userId);
}
@Delete(':id')
async deleteUploadedImage(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.uploadService.deleteUploadedImage(id, user.userId);
}
}

View file

@ -4,8 +4,8 @@ import { UploadService } from './upload.service';
import { StorageService } from './storage.service';
@Module({
controllers: [UploadController],
providers: [UploadService, StorageService],
exports: [UploadService, StorageService],
controllers: [UploadController],
providers: [UploadService, StorageService],
exports: [UploadService, StorageService],
})
export class UploadModule {}

View file

@ -1,10 +1,4 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
@ -13,111 +7,98 @@ import { StorageService } from './storage.service';
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
private readonly logger = new Logger(UploadService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private readonly storageService: StorageService,
) {}
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private readonly storageService: StorageService
) {}
async uploadImage(
userId: string,
file: Express.Multer.File,
): Promise<Image> {
try {
// Upload to storage
const { storagePath, publicUrl } = await this.storageService.uploadFile(
file.buffer,
userId,
file.originalname,
file.mimetype,
);
async uploadImage(userId: string, file: Express.Multer.File): Promise<Image> {
try {
// Upload to storage
const { storagePath, publicUrl } = await this.storageService.uploadFile(
file.buffer,
userId,
file.originalname,
file.mimetype
);
// Get image dimensions (would need sharp for this)
// For now, we'll skip dimensions
// Get image dimensions (would need sharp for this)
// For now, we'll skip dimensions
// Create database record
const result = await this.db
.insert(images)
.values({
userId,
prompt: file.originalname, // Use filename as prompt for uploaded images
storagePath,
publicUrl,
filename: file.originalname,
format: file.mimetype.split('/')[1],
fileSize: file.size,
})
.returning();
// Create database record
const result = await this.db
.insert(images)
.values({
userId,
prompt: file.originalname, // Use filename as prompt for uploaded images
storagePath,
publicUrl,
filename: file.originalname,
format: file.mimetype.split('/')[1],
fileSize: file.size,
})
.returning();
return result[0];
} catch (error) {
this.logger.error('Error uploading image', error);
throw error;
}
}
return result[0];
} catch (error) {
this.logger.error('Error uploading image', error);
throw error;
}
}
async uploadMultiple(
userId: string,
files: Express.Multer.File[],
): Promise<Image[]> {
const results: Image[] = [];
async uploadMultiple(userId: string, files: Express.Multer.File[]): Promise<Image[]> {
const results: Image[] = [];
for (const file of files) {
try {
const image = await this.uploadImage(userId, file);
results.push(image);
} catch (error) {
this.logger.error(`Error uploading file ${file.originalname}`, error);
// Continue with other files
}
}
for (const file of files) {
try {
const image = await this.uploadImage(userId, file);
results.push(image);
} catch (error) {
this.logger.error(`Error uploading file ${file.originalname}`, error);
// Continue with other files
}
}
return results;
}
return results;
}
async deleteUploadedImage(id: string, userId: string): Promise<void> {
try {
// Get the image
const result = await this.db
.select()
.from(images)
.where(eq(images.id, id))
.limit(1);
async deleteUploadedImage(id: string, userId: string): Promise<void> {
try {
// Get the image
const result = await this.db.select().from(images).where(eq(images.id, id)).limit(1);
if (result.length === 0) {
throw new NotFoundException(`Image with id ${id} not found`);
}
if (result.length === 0) {
throw new NotFoundException(`Image with id ${id} not found`);
}
const image = result[0];
const image = result[0];
// Verify ownership
if (image.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Verify ownership
if (image.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Delete from storage
try {
await this.storageService.deleteFile(image.storagePath);
} catch (error) {
this.logger.warn(`Failed to delete file from storage: ${image.storagePath}`);
// Continue with database deletion
}
// Delete from storage
try {
await this.storageService.deleteFile(image.storagePath);
} catch (error) {
this.logger.warn(`Failed to delete file from storage: ${image.storagePath}`);
// Continue with database deletion
}
// Delete image-tag relations
await this.db.delete(imageTags).where(eq(imageTags.imageId, id));
// Delete image-tag relations
await this.db.delete(imageTags).where(eq(imageTags.imageId, id));
// Delete the database record
await this.db.delete(images).where(eq(images.id, id));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error deleting uploaded image ${id}`, error);
throw error;
}
}
// Delete the database record
await this.db.delete(images).where(eq(images.id, id));
} catch (error) {
if (error instanceof NotFoundException || error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Error deleting uploaded image ${id}`, error);
throw error;
}
}
}

View file

@ -1,23 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
}
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
}
}

View file

@ -3,6 +3,7 @@
### 📦 What was created:
**1. Collection Schema** (`config.ts`)
- Full model specifications
- Performance metrics (speed, quality, reliability)
- Pricing & availability
@ -14,6 +15,7 @@
- Related content
**2. Example Models**
- FLUX Schnell (fast, general purpose)
- FLUX Dev (professional, balanced)
@ -38,32 +40,31 @@
```yaml
---
name: "Model Name"
slug: "model-slug"
provider: "Provider Name"
description: "Short description"
type: "text-to-image"
category: "general"
availability: "available"
name: 'Model Name'
slug: 'model-slug'
provider: 'Provider Name'
description: 'Short description'
type: 'text-to-image'
category: 'general'
availability: 'available'
featured: true
pricing:
free: false
pro: true
enterprise: true
performance:
speed: "~5 seconds"
speed: '~5 seconds'
speedScore: 4
quality: "excellent"
quality: 'excellent'
qualityScore: 4
strengths:
- "Strength 1"
- "Strength 2"
- 'Strength 1'
- 'Strength 2'
bestFor:
- "Use case 1"
language: "en"
- 'Use case 1'
language: 'en'
lastUpdated: 2025-01-15T00:00:00.000Z
---
Content here...
```

View file

@ -68,22 +68,24 @@ Each case study follows a four-part narrative:
### Key Metrics
**metrics** (array of objects, optional):
- **label** (string) - e.g., "Time Saved", "Cost Reduction"
- **value** (string) - e.g., "80%", "€2,000/month"
- **description** (string, optional) - Additional context
- **icon** (string, optional) - Emoji or icon
Example:
```yaml
metrics:
- label: "Cost Reduction"
value: "90%"
description: "Saved €54,000 per year on photography"
icon: "💰"
- label: "Images Generated"
value: "10,000+"
description: "Professional product photos in first 6 months"
icon: "📸"
- label: 'Cost Reduction'
value: '90%'
description: 'Saved €54,000 per year on photography'
icon: '💰'
- label: 'Images Generated'
value: '10,000+'
description: 'Professional product photos in first 6 months'
icon: '📸'
```
### Features & Models Used
@ -97,22 +99,23 @@ metrics:
```yaml
beforeAfter:
before:
description: "Hiring photographers for every product"
image: "/images/before.jpg"
description: 'Hiring photographers for every product'
image: '/images/before.jpg'
metrics:
- "€5,000/month on photography"
- "2 weeks per photo shoot"
- '€5,000/month on photography'
- '2 weeks per photo shoot'
after:
description: "Generate unlimited product photos on-demand"
image: "/images/after.jpg"
description: 'Generate unlimited product photos on-demand'
image: '/images/after.jpg'
metrics:
- "€500/month for Picture Pro"
- "Minutes per image"
- '€500/month for Picture Pro'
- 'Minutes per image'
```
### Example Images
**exampleImages** (array of objects):
- **url** (string)
- **caption** (string, optional)
- **prompt** (string, optional)
@ -121,29 +124,30 @@ beforeAfter:
```yaml
timeline:
- date: "January 2025"
milestone: "Started using Picture"
- date: "March 2025"
milestone: "Scaled to 10,000 images"
- date: 'January 2025'
milestone: 'Started using Picture'
- date: 'March 2025'
milestone: 'Scaled to 10,000 images'
```
### Key Takeaways
**keyTakeaways** (array of strings, required):
```yaml
keyTakeaways:
- "AI image generation reduced costs by 90%"
- "Team productivity increased 5x"
- "Able to test more product variations"
- 'AI image generation reduced costs by 90%'
- 'Team productivity increased 5x'
- 'Able to test more product variations'
```
### Testimonial (Optional)
```yaml
testimonial:
quote: "Picture transformed how we create product photos"
author: "Sarah Chen"
role: "Creative Director"
quote: 'Picture transformed how we create product photos'
author: 'Sarah Chen'
role: 'Creative Director'
```
### Technical Details (Optional)
@ -151,14 +155,14 @@ testimonial:
```yaml
technicalDetails:
integrations:
- "Shopify"
- "WordPress"
workflow: "Automated workflow description"
- 'Shopify'
- 'WordPress'
workflow: 'Automated workflow description'
team:
size: 5
roles:
- "Designer"
- "Marketer"
- 'Designer'
- 'Marketer'
```
### Related Content
@ -184,77 +188,77 @@ technicalDetails:
```yaml
cta:
text: "Start Your Free Trial"
url: "/signup"
text: 'Start Your Free Trial'
url: '/signup'
```
## Example Case Study
```markdown
---
title: "How Luxe Fashion Reduced Photography Costs by 90%"
description: "Luxe Fashion e-commerce store saves €54,000/year on product photography using Picture AI"
coverImage: "/images/case-studies/luxe-fashion-hero.jpg"
title: 'How Luxe Fashion Reduced Photography Costs by 90%'
description: 'Luxe Fashion e-commerce store saves €54,000/year on product photography using Picture AI'
coverImage: '/images/case-studies/luxe-fashion-hero.jpg'
company:
name: "Luxe Fashion"
logo: "/images/logos/luxe-fashion.svg"
website: "https://luxefashion.example"
industry: "E-commerce Fashion"
size: "small"
location: "Berlin, Germany"
name: 'Luxe Fashion'
logo: '/images/logos/luxe-fashion.svg'
website: 'https://luxefashion.example'
industry: 'E-commerce Fashion'
size: 'small'
location: 'Berlin, Germany'
contact:
name: "Sarah Chen"
role: "Creative Director"
avatar: "/images/people/sarah-chen.jpg"
quote: "Picture transformed our entire content creation workflow"
name: 'Sarah Chen'
role: 'Creative Director'
avatar: '/images/people/sarah-chen.jpg'
quote: 'Picture transformed our entire content creation workflow'
category: "ecommerce"
category: 'ecommerce'
tags:
- "product-photography"
- "e-commerce"
- "fashion"
- 'product-photography'
- 'e-commerce'
- 'fashion'
featured: true
trending: false
language: "en"
language: 'en'
challenge: "We were spending €5,000/month on professional photographers..."
solution: "Picture AI enabled us to generate unlimited product photos..."
implementation: "We integrated Picture into our Shopify workflow..."
results: "In 6 months, we generated over 10,000 product images..."
challenge: 'We were spending €5,000/month on professional photographers...'
solution: 'Picture AI enabled us to generate unlimited product photos...'
implementation: 'We integrated Picture into our Shopify workflow...'
results: 'In 6 months, we generated over 10,000 product images...'
metrics:
- label: "Cost Reduction"
value: "90%"
description: "Saved €54,000 per year"
icon: "💰"
- label: "Images Generated"
value: "10,000+"
description: "Professional product photos"
icon: "📸"
- label: "Time Saved"
value: "20 hours/week"
description: "Team productivity boost"
icon: "⏱️"
- label: 'Cost Reduction'
value: '90%'
description: 'Saved €54,000 per year'
icon: '💰'
- label: 'Images Generated'
value: '10,000+'
description: 'Professional product photos'
icon: '📸'
- label: 'Time Saved'
value: '20 hours/week'
description: 'Team productivity boost'
icon: '⏱️'
featuresUsed:
- "flux-pro"
- "batch-generation"
- "api-integration"
- 'flux-pro'
- 'batch-generation'
- 'api-integration'
modelsUsed:
- "flux-1-1-pro"
- "flux-dev"
- 'flux-1-1-pro'
- 'flux-dev'
keyTakeaways:
- "AI image generation reduced costs by 90%"
- "Team can test more product variations"
- "Faster time-to-market for new products"
- 'AI image generation reduced costs by 90%'
- 'Team can test more product variations'
- 'Faster time-to-market for new products'
testimonial:
quote: "Picture transformed how we create product photos. What used to take weeks now takes minutes."
author: "Sarah Chen"
role: "Creative Director"
quote: 'Picture transformed how we create product photos. What used to take weeks now takes minutes.'
author: 'Sarah Chen'
role: 'Creative Director'
publishDate: 2025-01-20T00:00:00Z
lastUpdated: 2025-01-20T00:00:00Z
@ -374,6 +378,7 @@ Located in `/src/utils/caseStudies.ts`:
## File Naming Convention
Use kebab-case for file names:
- `company-name-brief-description.md`
- Example: `luxe-fashion-ecommerce.md`

View file

@ -11,6 +11,7 @@ Located in: `/apps/landing/src/content/config.ts`
### Core Fields
#### Required Fields
- **title** (string) - The name of the template
- **description** (string) - Brief description of what the template creates
- **icon** (string) - Emoji icon for visual identification
@ -20,6 +21,7 @@ Located in: `/apps/landing/src/content/config.ts`
- **recommendedModel** (string) - Best AI model for this template
#### Template Variables
- **variables** (array) - List of variable definitions:
- `name` - Variable identifier in the template
- `description` - User-friendly label
@ -27,12 +29,14 @@ Located in: `/apps/landing/src/content/config.ts`
- `required` - Whether the variable must be filled
#### Organization
- **category** - Main category (social-media, product-photography, character-design, etc.)
- **tags** - Array of searchable tags
- **difficulty** - Skill level required
- **subcategory** - Optional subcategory for finer organization
#### Recommendations
- **recommendedModel** - Primary AI model
- **alternativeModels** - Array of alternative models
- **recommendedSettings** - Object with:
@ -42,6 +46,7 @@ Located in: `/apps/landing/src/content/config.ts`
- `negativePrompt` - What to avoid
#### Examples & Variations
- **exampleImages** - Array of example outputs:
- `url` - Image path
- `prompt` - Exact prompt used
@ -52,6 +57,7 @@ Located in: `/apps/landing/src/content/config.ts`
- `description` - What makes it different
#### Engagement Metrics
- **uses** (number) - Total usage count
- **likes** (number) - User likes
- **saves** (number) - Times saved
@ -59,17 +65,20 @@ Located in: `/apps/landing/src/content/config.ts`
- **successRate** (number, 0-100) - Success percentage
#### Status Flags
- **featured** (boolean) - Show in featured section
- **popular** (boolean) - Mark as popular
- **trending** (boolean) - Currently trending
- **premium** (boolean) - Requires premium access
#### Metadata
- **publishDate** (date) - First published
- **lastUpdated** (date) - Last modification
- **language** (string) - Content language (en, de, fr, etc.)
#### Content & Guidance
- **useCases** - Array of use case strings
- **idealFor** - Array of target audience strings
- **tips** - Array of helpful tips
@ -99,35 +108,35 @@ apps/landing/src/content/promptTemplates/
```markdown
---
title: "Product Photography for Instagram"
description: "Create stunning product shots optimized for Instagram"
icon: "📸"
title: 'Product Photography for Instagram'
description: 'Create stunning product shots optimized for Instagram'
icon: '📸'
promptTemplate: "Professional product photography of {product}, {style} style, {lighting} lighting, on {background}, {angle} angle, high detail, commercial quality"
promptTemplate: 'Professional product photography of {product}, {style} style, {lighting} lighting, on {background}, {angle} angle, high detail, commercial quality'
variables:
- name: "product"
description: "The product to photograph"
placeholder: "sneakers / watch / coffee mug"
- name: 'product'
description: 'The product to photograph'
placeholder: 'sneakers / watch / coffee mug'
required: true
- name: "style"
description: "Photography style"
placeholder: "minimalist / editorial / lifestyle"
- name: 'style'
description: 'Photography style'
placeholder: 'minimalist / editorial / lifestyle'
required: true
category: "product-photography"
category: 'product-photography'
tags:
- "product"
- "instagram"
- "commercial"
- 'product'
- 'instagram'
- 'commercial'
difficulty: "beginner"
recommendedModel: "flux-1-1-pro"
difficulty: 'beginner'
recommendedModel: 'flux-1-1-pro'
alternativeModels:
- "flux-dev"
- 'flux-dev'
recommendedSettings:
aspectRatio: "1:1"
aspectRatio: '1:1'
steps: 2
guidanceScale: 3.5
@ -157,6 +166,7 @@ Your content here...
Located in: `/apps/landing/src/utils/promptTemplates.ts`
### Template Retrieval
- `getAllPromptTemplates()` - Get all templates, sorted by uses
- `getFeaturedTemplates(limit)` - Get featured templates
- `getPopularTemplates(limit)` - Get popular templates
@ -164,12 +174,14 @@ Located in: `/apps/landing/src/utils/promptTemplates.ts`
- `getTemplateBySlug(slug)` - Get single template
### Filtering
- `getTemplatesByCategory(category)` - Filter by category
- `getTemplatesByDifficulty(difficulty)` - Filter by difficulty
- `getTemplatesByTag(tag)` - Filter by tag
- `getTemplatesByModel(model)` - Filter by AI model
### Search & Sort
- `searchTemplates(query)` - Full-text search
- `sortTemplates(templates, sortBy)` - Sort by various criteria
- `getMostUsedTemplates(limit)` - Top used templates
@ -177,19 +189,23 @@ Located in: `/apps/landing/src/utils/promptTemplates.ts`
- `getMostSavedTemplates(limit)` - Most saved templates
### Analytics
- `getAllCategories()` - Get categories with counts and icons
- `getAllTags()` - Get tags with usage counts
- `getTemplateStats()` - Comprehensive statistics
### Template Manipulation
- `fillTemplate(template, variables)` - Replace {variables} with values
- `extractVariables(template)` - Get all {variables} from template
- `validateTemplateVariables(template, providedVariables)` - Validate inputs
### Related Content
- `getRelatedTemplates(currentTemplate, limit)` - Get related templates
### UI Helpers
- `formatCategoryName(category)` - Format for display
- `getDifficultyColor(difficulty)` - Get badge color
@ -198,14 +214,17 @@ Located in: `/apps/landing/src/utils/promptTemplates.ts`
Located in: `/apps/landing/src/components/promptTemplates/`
### TemplateCard.astro
Reusable card component for displaying template summaries.
**Props:**
- `template` - PromptTemplateEntry
- `featured` - boolean (optional)
- `compact` - boolean (optional)
**Features:**
- Icon and title
- Difficulty badge
- Status badges (featured, popular, trending)
@ -215,12 +234,15 @@ Reusable card component for displaying template summaries.
- Recommended model
### PromptBuilder.astro
Interactive form for building prompts from templates.
**Props:**
- `template` - PromptTemplateEntry
**Features:**
- Dynamic form fields based on template variables
- Real-time prompt generation
- Copy to clipboard functionality
@ -228,28 +250,34 @@ Interactive form for building prompts from templates.
- Required field validation
### CategoryGrid.astro
Grid display of all categories.
**Props:**
- `categories` - Array of category objects
- `interactive` - boolean (enables click-to-filter)
**Features:**
- Icon and category name
- Template count
- Hover effects
- Interactive filtering (when enabled)
### TemplateFilters.astro
Filter and sort controls.
**Props:**
- `categories` - Array of categories
- `difficulties` - Array of difficulty levels
- `models` - Array of AI models
- `stats` - Statistics object
**Features:**
- Category filter dropdown
- Difficulty filter dropdown
- Model filter dropdown
@ -258,14 +286,17 @@ Filter and sort controls.
- Custom events for filter changes
### FeaturedSection.astro
Section component for displaying featured templates.
**Props:**
- `templates` - Array of PromptTemplateEntry
- `title` - string (optional)
- `description` - string (optional)
**Features:**
- Responsive grid layout
- Uses TemplateCard components
- Customizable heading
@ -273,9 +304,11 @@ Section component for displaying featured templates.
## Pages
### Index Page
**Path:** `/apps/landing/src/pages/prompt-templates/index.astro`
**Sections:**
1. Hero with search and stats
2. Filter bar (sticky)
3. Featured templates section
@ -284,6 +317,7 @@ Section component for displaying featured templates.
6. CTA section
**Features:**
- Real-time client-side filtering
- Search functionality
- Sort options
@ -292,9 +326,11 @@ Section component for displaying featured templates.
- Responsive grid layouts
### Detail Page
**Path:** `/apps/landing/src/pages/prompt-templates/[slug].astro`
**Sections:**
1. Hero with template info and stats
2. Two-column layout:
- **Main Content:**
@ -316,6 +352,7 @@ Section component for displaying featured templates.
4. CTA section
**Features:**
- Interactive prompt builder with live preview
- Copy to clipboard
- Breadcrumb navigation
@ -326,18 +363,22 @@ Section component for displaying featured templates.
## SEO & Best Practices
### Title Format
`{Template Title} - AI Prompt Template | Picture`
### Description Format
Keep under 160 characters, focus on benefits and use cases.
### URL Structure
`/prompt-templates` - Index page
`/prompt-templates/{slug}` - Detail page
`/prompt-templates?category={category}` - Category filter
`/prompt-templates?tag={tag}` - Tag filter
### Content Guidelines
1. **Title** - Clear, descriptive, 3-7 words
2. **Description** - One sentence benefit statement
3. **Icon** - Relevant emoji
@ -347,6 +388,7 @@ Keep under 160 characters, focus on benefits and use cases.
7. **Use Cases** - 3-6 specific scenarios
### Engagement Tips
- Set realistic success rates
- Use clear, specific placeholders
- Include example images when possible
@ -356,19 +398,25 @@ Keep under 160 characters, focus on benefits and use cases.
## Naming Conventions
### File Names
Use kebab-case with descriptive names:
- `instagram-product-showcase.md`
- `character-design-rpg.md`
- `cinematic-portrait.md`
### Category Slugs
Use kebab-case:
- `product-photography`
- `character-design`
- `social-media`
### Variable Names
Use snake_case:
- `product_type`
- `lighting_style`
- `color_scheme`
@ -406,6 +454,7 @@ Future enhancement: URL parameters to pass prompt directly to app.
## Localization
Templates support multiple languages:
- Each language has its own folder
- Translations should maintain same slug structure
- Variables can be localized
@ -414,6 +463,7 @@ Templates support multiple languages:
## Analytics Tracking
Consider tracking:
- Template views
- Template uses (prompt generation)
- Copy to clipboard events
@ -425,6 +475,7 @@ Consider tracking:
## Future Enhancements
Potential improvements:
- User-submitted templates
- Template ratings/reviews
- Save to favorites

View file

@ -86,7 +86,7 @@ Das Projekt verwendet **Tailwind CSS** für Styling:
```html
<!-- Beispiel -->
<div class="container mx-auto px-4 py-8">
<h1 class="text-4xl font-bold">Welcome to Picture</h1>
<h1 class="text-4xl font-bold">Welcome to Picture</h1>
</div>
```
@ -155,10 +155,10 @@ import Layout from '../layouts/Layout.astro';
---
<Layout title="About - Picture">
<main>
<h1>About Us</h1>
<p>Welcome to Picture</p>
</main>
<main>
<h1>About Us</h1>
<p>Welcome to Picture</p>
</main>
</Layout>
```
@ -170,23 +170,23 @@ Die Seite ist dann verfügbar unter: `/about`
---
// src/components/Hero.astro
interface Props {
title: string;
subtitle?: string;
title: string;
subtitle?: string;
}
const { title, subtitle } = Astro.props;
---
<section class="hero">
<h1>{title}</h1>
{subtitle && <p>{subtitle}</p>}
<h1>{title}</h1>
{subtitle && <p>{subtitle}</p>}
</section>
<style>
.hero {
text-align: center;
padding: 4rem 2rem;
}
.hero {
text-align: center;
padding: 4rem 2rem;
}
</style>
```
@ -199,8 +199,8 @@ import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
integrations: [tailwind()],
output: 'static',
integrations: [tailwind()],
output: 'static',
});
```
@ -208,11 +208,11 @@ export default defineConfig({
```javascript
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
};
```
@ -231,8 +231,8 @@ Astro ist von Haus aus SEO-optimiert:
---
// src/layouts/Layout.astro
interface Props {
title: string;
description?: string;
title: string;
description?: string;
}
const { title, description } = Astro.props;
@ -240,16 +240,16 @@ const { title, description } = Astro.props;
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
{description && <meta name="description" content={description} />}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<slot />
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
{description && <meta name="description" content={description} />}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<slot />
</body>
</html>
```

View file

@ -2,62 +2,82 @@
import { t } from '../i18n';
---
<section class="relative py-24 overflow-hidden">
<section class="relative overflow-hidden py-24">
<!-- Background -->
<div class="absolute inset-0 bg-gradient-to-r from-primary-900/30 via-secondary-900/30 to-primary-900/30"></div>
<div
class="via-secondary-900/30 absolute inset-0 bg-gradient-to-r from-primary-900/30 to-primary-900/30"
>
</div>
<div class="relative z-10 container mx-auto px-4">
<div class="max-w-4xl mx-auto">
<div class="container relative z-10 mx-auto px-4">
<div class="mx-auto max-w-4xl">
<!-- Content Card -->
<div class="relative p-12 bg-gradient-to-br from-dark-elevated/80 to-dark-surface/80 rounded-3xl border border-dark-border backdrop-blur-sm">
<div
class="relative rounded-3xl border border-dark-border bg-gradient-to-br from-dark-elevated/80 to-dark-surface/80 p-12 backdrop-blur-sm"
>
<!-- Glow Effect -->
<div class="absolute -inset-1 bg-gradient-to-r from-primary-600 to-secondary-600 rounded-3xl opacity-20 blur-xl"></div>
<div
class="absolute -inset-1 rounded-3xl bg-gradient-to-r from-primary-600 to-secondary-600 opacity-20 blur-xl"
>
</div>
<div class="relative text-center">
<!-- Heading -->
<h2 class="text-4xl md:text-5xl font-bold text-white mb-6">
<h2 class="mb-6 text-4xl font-bold text-white md:text-5xl">
{t('cta.title')}
</h2>
<!-- Subtitle -->
<p class="text-xl text-gray-300 mb-10 max-w-2xl mx-auto">
<p class="mx-auto mb-10 max-w-2xl text-xl text-gray-300">
{t('cta.subtitle')}
</p>
<!-- CTA Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="#"
class="group px-8 py-4 bg-gradient-to-r from-primary-600 to-secondary-600 hover:from-primary-700 hover:to-secondary-700 text-white rounded-lg font-semibold text-lg transition-all duration-300 shadow-lg shadow-primary/50 hover:shadow-primary/70 hover:scale-105"
class="hover:to-secondary-700 group rounded-lg bg-gradient-to-r from-primary-600 to-secondary-600 px-8 py-4 text-lg font-semibold text-white shadow-lg shadow-primary/50 transition-all duration-300 hover:scale-105 hover:from-primary-700 hover:shadow-primary/70"
>
{t('cta.button_primary')}
<span class="inline-block ml-2 group-hover:translate-x-1 transition-transform">→</span>
<span class="ml-2 inline-block transition-transform group-hover:translate-x-1">→</span
>
</a>
<a
href="#"
class="px-8 py-4 bg-transparent hover:bg-dark-elevated text-white rounded-lg font-semibold text-lg transition-all duration-300 border-2 border-dark-border hover:border-primary"
class="rounded-lg border-2 border-dark-border bg-transparent px-8 py-4 text-lg font-semibold text-white transition-all duration-300 hover:border-primary hover:bg-dark-elevated"
>
{t('cta.button_secondary')}
</a>
</div>
<!-- Trust Indicators -->
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-6 text-sm text-gray-400">
<div
class="mt-10 flex flex-col items-center justify-center gap-6 text-sm text-gray-400 sm:flex-row"
>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-success" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
<svg class="h-5 w-5 text-success" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"></path>
</svg>
<span>{t('cta.trust.no_credit_card')}</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-success" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
<svg class="h-5 w-5 text-success" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"></path>
</svg>
<span>{t('cta.trust.free_plan')}</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-success" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
<svg class="h-5 w-5 text-success" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"></path>
</svg>
<span>{t('cta.trust.cancel_anytime')}</span>
</div>
@ -68,6 +88,12 @@ import { t } from '../i18n';
</div>
<!-- Decorative Blobs -->
<div class="absolute top-0 left-1/4 w-96 h-96 bg-primary/20 rounded-full filter blur-3xl opacity-30"></div>
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-secondary/20 rounded-full filter blur-3xl opacity-30"></div>
<div
class="absolute left-1/4 top-0 h-96 w-96 rounded-full bg-primary/20 opacity-30 blur-3xl filter"
>
</div>
<div
class="absolute bottom-0 right-1/4 h-96 w-96 rounded-full bg-secondary/20 opacity-30 blur-3xl filter"
>
</div>
</section>

View file

@ -5,34 +5,54 @@ import { t } from '../i18n';
const currentYear = new Date().getFullYear();
---
<footer class="relative bg-dark-bg border-t border-dark-border">
<footer class="relative border-t border-dark-border bg-dark-bg">
<div class="container mx-auto px-4 py-12">
<div class="grid md:grid-cols-4 gap-8 mb-8">
<div class="mb-8 grid gap-8 md:grid-cols-4">
<!-- Brand -->
<div class="col-span-2 md:col-span-1">
<div class="flex items-center gap-2 mb-4">
<div class="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
<div class="mb-4 flex items-center gap-2">
<div
class="bg-gradient-to-r from-primary to-secondary bg-clip-text text-2xl font-bold text-transparent"
>
Picture
</div>
</div>
<p class="text-gray-400 text-sm mb-4">
<p class="mb-4 text-sm text-gray-400">
{t('footer.description')}
</p>
<!-- Social Links -->
<div class="flex gap-4">
<a href="#" class="text-gray-400 hover:text-primary transition-colors" aria-label="Twitter">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z"/>
<a
href="#"
class="text-gray-400 transition-colors hover:text-primary"
aria-label="Twitter"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z"
></path>
</svg>
</a>
<a href="#" class="text-gray-400 hover:text-primary transition-colors" aria-label="GitHub">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
<a
href="#"
class="text-gray-400 transition-colors hover:text-primary"
aria-label="GitHub"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
></path>
</svg>
</a>
<a href="#" class="text-gray-400 hover:text-primary transition-colors" aria-label="Discord">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
<a
href="#"
class="text-gray-400 transition-colors hover:text-primary"
aria-label="Discord"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
></path>
</svg>
</a>
</div>
@ -40,51 +60,139 @@ const currentYear = new Date().getFullYear();
<!-- Product -->
<div>
<h3 class="text-white font-semibold mb-4">{t('footer.product.title')}</h3>
<h3 class="mb-4 font-semibold text-white">{t('footer.product.title')}</h3>
<ul class="space-y-2">
<li><a href={localizePath('/features')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.product.features')}</a></li>
<li><a href={localizePath('/use-cases')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.product.use_cases')}</a></li>
<li><a href={localizePath('/comparisons')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.product.comparisons')}</a></li>
<li><a href="#" class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.product.pricing')}</a></li>
<li><a href="#" class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.product.models')}</a></li>
<li><a href="#" class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.product.api')}</a></li>
<li>
<a
href={localizePath('/features')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.product.features')}</a
>
</li>
<li>
<a
href={localizePath('/use-cases')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.product.use_cases')}</a
>
</li>
<li>
<a
href={localizePath('/comparisons')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.product.comparisons')}</a
>
</li>
<li>
<a href="#" class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.product.pricing')}</a
>
</li>
<li>
<a href="#" class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.product.models')}</a
>
</li>
<li>
<a href="#" class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.product.api')}</a
>
</li>
</ul>
</div>
<!-- Company -->
<div>
<h3 class="text-white font-semibold mb-4">{t('footer.company.title')}</h3>
<h3 class="mb-4 font-semibold text-white">{t('footer.company.title')}</h3>
<ul class="space-y-2">
<li><a href="#" class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.company.about')}</a></li>
<li><a href={localizePath('/blog')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.company.blog')}</a></li>
<li><a href={localizePath('/testimonials')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.company.testimonials')}</a></li>
<li><a href={localizePath('/faq')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.company.faq')}</a></li>
<li><a href="#" class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.company.careers')}</a></li>
<li><a href="#" class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.company.contact')}</a></li>
<li>
<a href="#" class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.company.about')}</a
>
</li>
<li>
<a
href={localizePath('/blog')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.company.blog')}</a
>
</li>
<li>
<a
href={localizePath('/testimonials')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.company.testimonials')}</a
>
</li>
<li>
<a
href={localizePath('/faq')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.company.faq')}</a
>
</li>
<li>
<a href="#" class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.company.careers')}</a
>
</li>
<li>
<a href="#" class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.company.contact')}</a
>
</li>
</ul>
</div>
<!-- Legal -->
<div>
<h3 class="text-white font-semibold mb-4">{t('footer.legal.title')}</h3>
<h3 class="mb-4 font-semibold text-white">{t('footer.legal.title')}</h3>
<ul class="space-y-2">
<li><a href={localizePath('/privacy')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.legal.privacy')}</a></li>
<li><a href={localizePath('/terms')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.legal.terms')}</a></li>
<li><a href={localizePath('/cookies')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.legal.cookie_policy')}</a></li>
<li><a href={localizePath('/imprint')} class="text-gray-400 hover:text-primary transition-colors text-sm">{t('footer.legal.licenses')}</a></li>
<li>
<a
href={localizePath('/privacy')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.legal.privacy')}</a
>
</li>
<li>
<a
href={localizePath('/terms')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.legal.terms')}</a
>
</li>
<li>
<a
href={localizePath('/cookies')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.legal.cookie_policy')}</a
>
</li>
<li>
<a
href={localizePath('/imprint')}
class="text-sm text-gray-400 transition-colors hover:text-primary"
>{t('footer.legal.licenses')}</a
>
</li>
</ul>
</div>
</div>
<!-- Bottom Bar -->
<div class="pt-8 border-t border-dark-border flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-gray-400 text-sm">
<div
class="flex flex-col items-center justify-between gap-4 border-t border-dark-border pt-8 md:flex-row"
>
<p class="text-sm text-gray-400">
{t('footer.bottom.copyright', { year: currentYear })}
</p>
<div class="flex gap-6 text-sm text-gray-400">
<a href="#" class="hover:text-primary transition-colors">{t('footer.bottom.status')}</a>
<a href="#" class="hover:text-primary transition-colors">{t('footer.bottom.documentation')}</a>
<a href="#" class="hover:text-primary transition-colors">{t('footer.bottom.support')}</a>
<a href="#" class="transition-colors hover:text-primary">{t('footer.bottom.status')}</a>
<a href="#" class="transition-colors hover:text-primary"
>{t('footer.bottom.documentation')}</a
>
<a href="#" class="transition-colors hover:text-primary">{t('footer.bottom.support')}</a>
</div>
</div>
</div>

View file

@ -2,21 +2,28 @@
import { t } from '../i18n';
---
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
<section class="relative flex min-h-screen items-center justify-center overflow-hidden">
<!-- Background Gradient -->
<div class="absolute inset-0 bg-gradient-to-br from-primary-900/20 via-dark-surface to-secondary-900/20"></div>
<div
class="to-secondary-900/20 absolute inset-0 bg-gradient-to-br from-primary-900/20 via-dark-surface"
>
</div>
<!-- Content -->
<div class="relative z-10 container mx-auto px-4 py-24">
<div class="max-w-4xl mx-auto text-center">
<div class="container relative z-10 mx-auto px-4 py-24">
<div class="mx-auto max-w-4xl text-center">
<!-- Badge -->
<div class="inline-flex items-center px-4 py-2 mb-8 bg-primary/10 border border-primary/20 rounded-full">
<div
class="mb-8 inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-4 py-2"
>
<span class="text-sm text-primary-300">{t('hero.badge')}</span>
</div>
<!-- Main Heading -->
<h1 class="text-6xl md:text-7xl lg:text-8xl font-bold mb-6 leading-tight">
<span class="bg-gradient-to-r from-primary-400 via-secondary-400 to-primary-400 bg-clip-text text-transparent animate-gradient">
<h1 class="mb-6 text-6xl font-bold leading-tight md:text-7xl lg:text-8xl">
<span
class="animate-gradient bg-gradient-to-r from-primary-400 via-secondary-400 to-primary-400 bg-clip-text text-transparent"
>
{t('hero.title')}
</span>
<br />
@ -24,39 +31,39 @@ import { t } from '../i18n';
</h1>
<!-- Subtitle -->
<p class="text-xl md:text-2xl text-gray-300 mb-12 max-w-2xl mx-auto">
<p class="mx-auto mb-12 max-w-2xl text-xl text-gray-300 md:text-2xl">
{t('hero.description')}
</p>
<!-- CTA Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="#"
class="group px-8 py-4 bg-gradient-to-r from-primary-600 to-secondary-600 hover:from-primary-700 hover:to-secondary-700 text-white rounded-lg font-semibold text-lg transition-all duration-300 shadow-lg shadow-primary/50 hover:shadow-primary/70 hover:scale-105"
class="hover:to-secondary-700 group rounded-lg bg-gradient-to-r from-primary-600 to-secondary-600 px-8 py-4 text-lg font-semibold text-white shadow-lg shadow-primary/50 transition-all duration-300 hover:scale-105 hover:from-primary-700 hover:shadow-primary/70"
>
{t('hero.cta_primary')}
<span class="inline-block ml-2 group-hover:translate-x-1 transition-transform">→</span>
<span class="ml-2 inline-block transition-transform group-hover:translate-x-1">→</span>
</a>
<a
href="#features"
class="px-8 py-4 bg-dark-elevated/50 hover:bg-dark-elevated text-white rounded-lg font-semibold text-lg transition-all duration-300 border border-dark-border hover:border-primary/50"
class="rounded-lg border border-dark-border bg-dark-elevated/50 px-8 py-4 text-lg font-semibold text-white transition-all duration-300 hover:border-primary/50 hover:bg-dark-elevated"
>
{t('hero.cta_secondary')}
</a>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-8 mt-16 max-w-2xl mx-auto">
<div class="mx-auto mt-16 grid max-w-2xl grid-cols-3 gap-8">
<div class="text-center">
<div class="text-3xl md:text-4xl font-bold text-white mb-2">50K+</div>
<div class="mb-2 text-3xl font-bold text-white md:text-4xl">50K+</div>
<div class="text-sm text-gray-400">{t('hero.stats.images')}</div>
</div>
<div class="text-center">
<div class="text-3xl md:text-4xl font-bold text-white mb-2">10+</div>
<div class="mb-2 text-3xl font-bold text-white md:text-4xl">10+</div>
<div class="text-sm text-gray-400">{t('hero.stats.models')}</div>
</div>
<div class="text-center">
<div class="text-3xl md:text-4xl font-bold text-white mb-2">99%</div>
<div class="mb-2 text-3xl font-bold text-white md:text-4xl">99%</div>
<div class="text-sm text-gray-400">{t('hero.stats.satisfaction')}</div>
</div>
</div>
@ -64,13 +71,20 @@ import { t } from '../i18n';
</div>
<!-- Decorative Elements -->
<div class="absolute top-1/4 left-10 w-72 h-72 bg-primary/30 rounded-full filter blur-3xl opacity-20 animate-pulse"></div>
<div class="absolute bottom-1/4 right-10 w-96 h-96 bg-secondary/30 rounded-full filter blur-3xl opacity-20 animate-pulse delay-1000"></div>
<div
class="absolute left-10 top-1/4 h-72 w-72 animate-pulse rounded-full bg-primary/30 opacity-20 blur-3xl filter"
>
</div>
<div
class="absolute bottom-1/4 right-10 h-96 w-96 animate-pulse rounded-full bg-secondary/30 opacity-20 blur-3xl filter delay-1000"
>
</div>
</section>
<style>
@keyframes gradient {
0%, 100% {
0%,
100% {
background-position: 0% 50%;
}
50% {

View file

@ -8,45 +8,58 @@ const languages = [
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'it', name: 'Italiano', flag: '🇮🇹' },
{ code: 'es', name: 'Español', flag: '🇪🇸' }
{ code: 'es', name: 'Español', flag: '🇪🇸' },
];
const currentLanguage = languages.find(lang => lang.code === currentLocale) || languages[0];
const currentLanguage = languages.find((lang) => lang.code === currentLocale) || languages[0];
---
<div class="language-switcher relative inline-block">
<button
class="flex items-center gap-2 px-4 py-2 bg-dark-elevated border border-dark-border rounded-lg hover:border-primary/50 transition-colors"
class="flex items-center gap-2 rounded-lg border border-dark-border bg-dark-elevated px-4 py-2 transition-colors hover:border-primary/50"
id="language-button"
aria-label="Change language"
>
<span class="text-xl">{currentLanguage.flag}</span>
<span class="text-sm text-white">{currentLanguage.name}</span>
<svg class="w-4 h-4 text-gray-400 transition-transform" id="dropdown-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
<svg
class="h-4 w-4 text-gray-400 transition-transform"
id="dropdown-arrow"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
<div
class="language-dropdown absolute right-0 mt-2 w-48 bg-dark-elevated border border-dark-border rounded-lg shadow-xl opacity-0 invisible transition-all duration-200 z-50"
class="language-dropdown invisible absolute right-0 z-50 mt-2 w-48 rounded-lg border border-dark-border bg-dark-elevated opacity-0 shadow-xl transition-all duration-200"
id="language-dropdown"
>
{languages.map((lang) => (
<button
data-lang={lang.code}
class={`w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-surface transition-colors ${
lang.code === currentLocale ? 'bg-dark-surface' : ''
}`}
>
<span class="text-xl">{lang.flag}</span>
<span class="text-sm text-white">{lang.name}</span>
{lang.code === currentLocale && (
<svg class="w-4 h-4 text-primary ml-auto" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
)}
</button>
))}
{
languages.map((lang) => (
<button
data-lang={lang.code}
class={`flex w-full items-center gap-3 px-4 py-3 transition-colors hover:bg-dark-surface ${
lang.code === currentLocale ? 'bg-dark-surface' : ''
}`}
>
<span class="text-xl">{lang.flag}</span>
<span class="text-sm text-white">{lang.name}</span>
{lang.code === currentLocale && (
<svg class="ml-auto h-4 w-4 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
)}
</button>
))
}
</div>
</div>

View file

@ -10,27 +10,28 @@ const { title } = Astro.props;
---
<div class="min-h-screen bg-dark-bg py-24">
<div class="container mx-auto px-4 max-w-4xl">
<div class="container mx-auto max-w-4xl px-4">
<!-- Back Button -->
<a
href={localizePath('/')}
class="inline-flex items-center gap-2 text-primary hover:text-primary-400 transition-colors mb-8"
class="mb-8 inline-flex items-center gap-2 text-primary transition-colors hover:text-primary-400"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"
></path>
</svg>
<span>{t('legal.back_home')}</span>
</a>
<!-- Page Header -->
<div class="mb-12">
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">{title}</h1>
<h1 class="mb-4 text-4xl font-bold text-white md:text-5xl">{title}</h1>
<p class="text-gray-400">{t('legal.last_updated')}: {new Date().toLocaleDateString()}</p>
</div>
<!-- Content -->
<div class="prose prose-invert prose-primary max-w-none">
<div class="bg-dark-surface border border-dark-border rounded-2xl p-8 md:p-12">
<div class="prose-primary prose prose-invert max-w-none">
<div class="rounded-2xl border border-dark-border bg-dark-surface p-8 md:p-12">
<slot />
</div>
</div>
@ -43,23 +44,23 @@ const { title } = Astro.props;
}
:global(.prose h2) {
@apply text-2xl font-bold text-white mt-8 mb-4;
@apply mb-4 mt-8 text-2xl font-bold text-white;
}
:global(.prose h3) {
@apply text-xl font-semibold text-white mt-6 mb-3;
@apply mb-3 mt-6 text-xl font-semibold text-white;
}
:global(.prose p) {
@apply text-gray-300 mb-4 leading-relaxed;
@apply mb-4 leading-relaxed text-gray-300;
}
:global(.prose ul) {
@apply list-disc pl-6 mb-4 space-y-2;
@apply mb-4 list-disc space-y-2 pl-6;
}
:global(.prose ol) {
@apply list-decimal pl-6 mb-4 space-y-2;
@apply mb-4 list-decimal space-y-2 pl-6;
}
:global(.prose li) {
@ -67,10 +68,10 @@ const { title } = Astro.props;
}
:global(.prose a) {
@apply text-primary hover:text-primary-400 transition-colors;
@apply text-primary transition-colors hover:text-primary-400;
}
:global(.prose strong) {
@apply text-white font-semibold;
@apply font-semibold text-white;
}
</style>

View file

@ -7,40 +7,50 @@ import { localizePath } from '../i18n';
const featuredTestimonials = await getFeaturedTestimonials();
---
<section class="relative py-24 bg-dark-surface overflow-hidden">
<section class="relative overflow-hidden bg-dark-surface py-24">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-b from-dark-bg via-dark-surface to-dark-bg opacity-50"></div>
<div
class="absolute inset-0 bg-gradient-to-b from-dark-bg via-dark-surface to-dark-bg opacity-50"
>
</div>
<div class="relative z-10 container mx-auto px-4">
<div class="container relative z-10 mx-auto px-4">
<!-- Section Header -->
<div class="max-w-3xl mx-auto text-center mb-16">
<div class="inline-flex items-center px-4 py-2 mb-6 bg-primary/10 border border-primary/20 rounded-full">
<div class="mx-auto mb-16 max-w-3xl text-center">
<div
class="mb-6 inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-4 py-2"
>
<span class="text-sm text-primary-300">💬 Testimonials</span>
</div>
<h2 class="text-4xl md:text-5xl font-bold text-white mb-6">
Loved by Creators Worldwide
</h2>
<h2 class="mb-6 text-4xl font-bold text-white md:text-5xl">Loved by Creators Worldwide</h2>
<p class="text-xl text-gray-400">
Join thousands of satisfied creators, designers, and businesses using Picture to bring their ideas to life.
Join thousands of satisfied creators, designers, and businesses using Picture to bring their
ideas to life.
</p>
</div>
<!-- Testimonials Grid -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
{featuredTestimonials.map(testimonial => (
<TestimonialCard testimonial={testimonial} featured={true} />
))}
<div class="mb-12 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{
featuredTestimonials.map((testimonial) => (
<TestimonialCard testimonial={testimonial} featured={true} />
))
}
</div>
<!-- View All Link -->
<div class="text-center">
<a
href={localizePath('/testimonials')}
class="inline-flex items-center gap-2 px-6 py-3 bg-dark-elevated border border-primary/30 hover:border-primary/50 text-primary hover:text-primary-400 rounded-lg font-semibold transition-all duration-300 hover:scale-105"
class="inline-flex items-center gap-2 rounded-lg border border-primary/30 bg-dark-elevated px-6 py-3 font-semibold text-primary transition-all duration-300 hover:scale-105 hover:border-primary/50 hover:text-primary-400"
>
<span>View All Testimonials</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
</div>

View file

@ -13,19 +13,26 @@ const { title, description, publishedAt, coverImage, category, tags } = post.dat
const readingTime = calculateReadingTime(post.body);
---
<article class="group bg-dark-elevated border border-dark-border rounded-2xl overflow-hidden hover:border-primary/50 transition-all duration-300 hover:scale-[1.02]">
<article
class="group overflow-hidden rounded-2xl border border-dark-border bg-dark-elevated transition-all duration-300 hover:scale-[1.02] hover:border-primary/50"
>
<!-- Cover Image -->
<a href={localizePath(`/blog/${post.slug}`)} class="block relative aspect-video overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-t from-dark-bg/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity z-10"></div>
<a href={localizePath(`/blog/${post.slug}`)} class="relative block aspect-video overflow-hidden">
<div
class="absolute inset-0 z-10 bg-gradient-to-t from-dark-bg/80 to-transparent opacity-0 transition-opacity group-hover:opacity-100"
>
</div>
<img
src={coverImage}
alt={title}
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
/>
<!-- Category Badge -->
<div class="absolute top-4 left-4 z-20">
<span class="px-3 py-1 bg-primary/90 backdrop-blur-sm text-white text-sm font-medium rounded-full capitalize">
<div class="absolute left-4 top-4 z-20">
<span
class="rounded-full bg-primary/90 px-3 py-1 text-sm font-medium capitalize text-white backdrop-blur-sm"
>
{t(`blog.categories.${category}`)}
</span>
</div>
@ -34,7 +41,7 @@ const readingTime = calculateReadingTime(post.body);
<!-- Content -->
<div class="p-6">
<!-- Meta Info -->
<div class="flex items-center gap-4 text-sm text-gray-400 mb-3">
<div class="mb-3 flex items-center gap-4 text-sm text-gray-400">
<time datetime={publishedAt.toISOString()}>
{formatDate(publishedAt, post.data.language)}
</time>
@ -43,24 +50,28 @@ const readingTime = calculateReadingTime(post.body);
</div>
<!-- Title -->
<a href={localizePath(`/blog/${post.slug}`)} class="block mb-3">
<h3 class="text-xl font-bold text-white group-hover:text-primary transition-colors line-clamp-2">
<a href={localizePath(`/blog/${post.slug}`)} class="mb-3 block">
<h3
class="line-clamp-2 text-xl font-bold text-white transition-colors group-hover:text-primary"
>
{title}
</h3>
</a>
<!-- Description -->
<p class="text-gray-300 mb-4 line-clamp-2">
<p class="mb-4 line-clamp-2 text-gray-300">
{description}
</p>
<!-- Tags -->
<div class="flex flex-wrap gap-2">
{tags.slice(0, 3).map(tag => (
<span class="px-2 py-1 bg-dark-surface text-gray-400 text-xs rounded">
#{tag}
</span>
))}
{
tags
.slice(0, 3)
.map((tag) => (
<span class="rounded bg-dark-surface px-2 py-1 text-xs text-gray-400">#{tag}</span>
))
}
</div>
</div>
</article>

View file

@ -49,9 +49,7 @@ const primaryMetric = data.metrics[0] || null;
{primaryMetric && (
<div class="absolute bottom-4 right-4 rounded-xl bg-white/90 px-4 py-2 backdrop-blur-sm dark:bg-gray-900/90">
<div class="text-2xl font-bold text-blue-600">{primaryMetric.value}</div>
<div class="text-xs text-gray-700 dark:text-gray-300">
{primaryMetric.label}
</div>
<div class="text-xs text-gray-700 dark:text-gray-300">{primaryMetric.label}</div>
</div>
)}
</div>
@ -137,12 +135,7 @@ const primaryMetric = data.metrics[0] || null;
{
data.views > 0 && (
<span class="flex items-center gap-1">
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -177,11 +170,8 @@ const primaryMetric = data.metrics[0] || null;
>
Read Story
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"
></path>
</svg>
</span>
</div>

View file

@ -80,10 +80,7 @@ const { categories } = Astro.props;
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Active filters:</span>
<div id="filter-tags" class="flex flex-wrap gap-2"></div>
<button
id="clear-filters"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
<button id="clear-filters" class="text-sm text-blue-600 hover:underline dark:text-blue-400">
Clear all
</button>
</div>

View file

@ -27,22 +27,26 @@ const isRecent = isRecentRelease(data.releaseDate);
<article class="changelog-entry">
<div class="entry-header">
<div class="flex items-start justify-between gap-4 flex-wrap">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<div class="flex items-center gap-3 mb-3 flex-wrap">
<div class="mb-3 flex flex-wrap items-center gap-3">
<VersionBadge version={data.version} type={data.type} showLabel={false} />
{data.highlighted && (
<span class="px-2 py-1 bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 rounded text-xs font-medium">
⭐ Highlighted
</span>
)}
{
data.highlighted && (
<span class="rounded border border-yellow-500/20 bg-yellow-500/10 px-2 py-1 text-xs font-medium text-yellow-400">
⭐ Highlighted
</span>
)
}
{isRecent && (
<span class="px-2 py-1 bg-green-500/10 text-green-400 border border-green-500/20 rounded text-xs font-medium">
🆕 New
</span>
)}
{
isRecent && (
<span class="rounded border border-green-500/20 bg-green-500/10 px-2 py-1 text-xs font-medium text-green-400">
🆕 New
</span>
)
}
</div>
<a href={`/changelog/${entry.id}`} class="entry-title-link">
@ -53,31 +57,44 @@ const isRecent = isRecentRelease(data.releaseDate);
<div class="entry-meta">
<span class="meta-item">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
{formattedDate} · {timeAgo}
</span>
<span class="meta-item">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
></path>
</svg>
{totalChanges} changes
</span>
<span class="meta-item platforms">
{data.platforms.map((platform) => (
<span class="platform-badge" title={getPlatformDisplayName(platform)}>
{getPlatformIcon(platform)}
</span>
))}
{
data.platforms.map((platform) => (
<span class="platform-badge" title={getPlatformDisplayName(platform)}>
{getPlatformIcon(platform)}
</span>
))
}
</span>
</div>
</div>
<a href={`/changelog/${entry.id}`} class="read-more-btn">
Read More
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"
></path>
</svg>
</a>
</div>
@ -86,119 +103,123 @@ const isRecent = isRecentRelease(data.releaseDate);
<!-- Changes Preview -->
<div class="entry-changes">
<!-- Features -->
{data.changes.features.length > 0 && (
<div class="change-section">
<h4 class="change-section-title">
<span class="change-icon">✨</span>
<span>New Features ({data.changes.features.length})</span>
</h4>
<ul class="change-list">
{data.changes.features.slice(0, detailed ? undefined : 3).map((feature) => (
<li class="change-item">
<span class="change-bullet">•</span>
<div>
<span class="change-title">{feature.title}</span>
{detailed && <p class="change-description">{feature.description}</p>}
</div>
</li>
))}
{!detailed && data.changes.features.length > 3 && (
<li class="change-item-more">
+ {data.changes.features.length - 3} more features
</li>
)}
</ul>
</div>
)}
{
data.changes.features.length > 0 && (
<div class="change-section">
<h4 class="change-section-title">
<span class="change-icon">✨</span>
<span>New Features ({data.changes.features.length})</span>
</h4>
<ul class="change-list">
{data.changes.features.slice(0, detailed ? undefined : 3).map((feature) => (
<li class="change-item">
<span class="change-bullet">•</span>
<div>
<span class="change-title">{feature.title}</span>
{detailed && <p class="change-description">{feature.description}</p>}
</div>
</li>
))}
{!detailed && data.changes.features.length > 3 && (
<li class="change-item-more">+ {data.changes.features.length - 3} more features</li>
)}
</ul>
</div>
)
}
<!-- Improvements -->
{data.changes.improvements.length > 0 && (
<div class="change-section">
<h4 class="change-section-title">
<span class="change-icon">🔧</span>
<span>Improvements ({data.changes.improvements.length})</span>
</h4>
<ul class="change-list">
{data.changes.improvements.slice(0, detailed ? undefined : 3).map((improvement) => (
<li class="change-item">
<span class="change-bullet">•</span>
<div>
<span class="change-title">{improvement.title}</span>
{detailed && <p class="change-description">{improvement.description}</p>}
</div>
</li>
))}
{!detailed && data.changes.improvements.length > 3 && (
<li class="change-item-more">
+ {data.changes.improvements.length - 3} more improvements
</li>
)}
</ul>
</div>
)}
{
data.changes.improvements.length > 0 && (
<div class="change-section">
<h4 class="change-section-title">
<span class="change-icon">🔧</span>
<span>Improvements ({data.changes.improvements.length})</span>
</h4>
<ul class="change-list">
{data.changes.improvements.slice(0, detailed ? undefined : 3).map((improvement) => (
<li class="change-item">
<span class="change-bullet">•</span>
<div>
<span class="change-title">{improvement.title}</span>
{detailed && <p class="change-description">{improvement.description}</p>}
</div>
</li>
))}
{!detailed && data.changes.improvements.length > 3 && (
<li class="change-item-more">
+ {data.changes.improvements.length - 3} more improvements
</li>
)}
</ul>
</div>
)
}
<!-- Bug Fixes -->
{data.changes.bugfixes.length > 0 && (
<div class="change-section">
<h4 class="change-section-title">
<span class="change-icon">🐛</span>
<span>Bug Fixes ({data.changes.bugfixes.length})</span>
</h4>
<ul class="change-list">
{data.changes.bugfixes.slice(0, detailed ? undefined : 3).map((bugfix) => (
<li class="change-item">
<span class="change-bullet">•</span>
<div>
{bugfix.severity && (
<span class={`severity-badge ${getSeverityColor(bugfix.severity)}`}>
{getSeverityIcon(bugfix.severity)}
</span>
)}
<span class="change-title">{bugfix.title}</span>
{detailed && <p class="change-description">{bugfix.description}</p>}
</div>
</li>
))}
{!detailed && data.changes.bugfixes.length > 3 && (
<li class="change-item-more">
+ {data.changes.bugfixes.length - 3} more bug fixes
</li>
)}
</ul>
</div>
)}
{
data.changes.bugfixes.length > 0 && (
<div class="change-section">
<h4 class="change-section-title">
<span class="change-icon">🐛</span>
<span>Bug Fixes ({data.changes.bugfixes.length})</span>
</h4>
<ul class="change-list">
{data.changes.bugfixes.slice(0, detailed ? undefined : 3).map((bugfix) => (
<li class="change-item">
<span class="change-bullet">•</span>
<div>
{bugfix.severity && (
<span class={`severity-badge ${getSeverityColor(bugfix.severity)}`}>
{getSeverityIcon(bugfix.severity)}
</span>
)}
<span class="change-title">{bugfix.title}</span>
{detailed && <p class="change-description">{bugfix.description}</p>}
</div>
</li>
))}
{!detailed && data.changes.bugfixes.length > 3 && (
<li class="change-item-more">+ {data.changes.bugfixes.length - 3} more bug fixes</li>
)}
</ul>
</div>
)
}
<!-- Breaking Changes -->
{data.changes.breaking.length > 0 && (
<div class="change-section breaking">
<h4 class="change-section-title">
<span class="change-icon">⚠️</span>
<span>Breaking Changes ({data.changes.breaking.length})</span>
</h4>
<ul class="change-list">
{data.changes.breaking.map((breaking) => (
<li class="change-item">
<span class="change-bullet">•</span>
<div>
<span class="change-title">{breaking.title}</span>
<p class="change-description">{breaking.description}</p>
{breaking.migration && (
<p class="migration-guide">
<strong>Migration:</strong> {breaking.migration}
</p>
)}
</div>
</li>
))}
</ul>
</div>
)}
{
data.changes.breaking.length > 0 && (
<div class="change-section breaking">
<h4 class="change-section-title">
<span class="change-icon">⚠️</span>
<span>Breaking Changes ({data.changes.breaking.length})</span>
</h4>
<ul class="change-list">
{data.changes.breaking.map((breaking) => (
<li class="change-item">
<span class="change-bullet">•</span>
<div>
<span class="change-title">{breaking.title}</span>
<p class="change-description">{breaking.description}</p>
{breaking.migration && (
<p class="migration-guide">
<strong>Migration:</strong> {breaking.migration}
</p>
)}
</div>
</li>
))}
</ul>
</div>
)
}
</div>
</article>
<style>
.changelog-entry {
@apply bg-dark-elevated border border-dark-border rounded-2xl p-6 hover:border-primary/30 transition;
@apply rounded-2xl border border-dark-border bg-dark-elevated p-6 transition hover:border-primary/30;
}
.entry-header {
@ -210,15 +231,15 @@ const isRecent = isRecentRelease(data.releaseDate);
}
.entry-title {
@apply text-2xl font-bold text-white mb-3 hover:text-primary transition;
@apply mb-3 text-2xl font-bold text-white transition hover:text-primary;
}
.entry-summary {
@apply text-gray-300 mb-4 leading-relaxed;
@apply mb-4 leading-relaxed text-gray-300;
}
.entry-meta {
@apply flex items-center gap-4 text-sm text-gray-500 flex-wrap;
@apply flex flex-wrap items-center gap-4 text-sm text-gray-500;
}
.meta-item {
@ -234,7 +255,7 @@ const isRecent = isRecentRelease(data.releaseDate);
}
.read-more-btn {
@apply px-4 py-2 bg-primary/10 border border-primary/20 text-primary rounded-lg text-sm font-medium hover:bg-primary/20 transition flex items-center gap-2 whitespace-nowrap;
@apply flex items-center gap-2 whitespace-nowrap rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm font-medium text-primary transition hover:bg-primary/20;
}
.entry-changes {
@ -242,15 +263,15 @@ const isRecent = isRecentRelease(data.releaseDate);
}
.change-section {
@apply bg-dark-bg rounded-xl p-4;
@apply rounded-xl bg-dark-bg p-4;
}
.change-section.breaking {
@apply bg-red-500/5 border border-red-500/20;
@apply border border-red-500/20 bg-red-500/5;
}
.change-section-title {
@apply flex items-center gap-2 text-sm font-semibold text-white mb-3;
@apply mb-3 flex items-center gap-2 text-sm font-semibold text-white;
}
.change-icon {
@ -262,11 +283,11 @@ const isRecent = isRecentRelease(data.releaseDate);
}
.change-item {
@apply flex items-start gap-2 text-gray-300 text-sm;
@apply flex items-start gap-2 text-sm text-gray-300;
}
.change-bullet {
@apply text-primary flex-shrink-0 mt-0.5;
@apply mt-0.5 flex-shrink-0 text-primary;
}
.change-title {
@ -274,18 +295,18 @@ const isRecent = isRecentRelease(data.releaseDate);
}
.change-description {
@apply text-gray-400 text-sm mt-1;
@apply mt-1 text-sm text-gray-400;
}
.change-item-more {
@apply text-gray-500 text-sm italic pl-4;
@apply pl-4 text-sm italic text-gray-500;
}
.severity-badge {
@apply text-xs mr-1;
@apply mr-1 text-xs;
}
.migration-guide {
@apply text-yellow-400 text-sm mt-2 p-2 bg-yellow-500/5 rounded;
@apply mt-2 rounded bg-yellow-500/5 p-2 text-sm text-yellow-400;
}
</style>

View file

@ -23,14 +23,12 @@ const label = getReleaseTypeDisplayName(type);
<div class={`version-badge ${colorClass}`}>
<span class="version-icon">{icon}</span>
<span class="version-number">{formattedVersion}</span>
{showLabel && (
<span class="version-label">{label}</span>
)}
{showLabel && <span class="version-label">{label}</span>}
</div>
<style>
.version-badge {
@apply inline-flex items-center gap-2 px-3 py-1.5 border rounded-lg text-sm font-medium;
@apply inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-medium;
}
.version-icon {
@ -42,6 +40,6 @@ const label = getReleaseTypeDisplayName(type);
}
.version-label {
@apply opacity-80 text-xs;
@apply text-xs opacity-80;
}
</style>

View file

@ -1,6 +1,11 @@
---
import type { CollectionEntry } from 'astro:content';
import { getWinnerBadgeColor, getWinnerBadgeText, getTypeDisplayName, getTypeIcon } from '../../utils/comparisons';
import {
getWinnerBadgeColor,
getWinnerBadgeText,
getTypeDisplayName,
getTypeIcon,
} from '../../utils/comparisons';
interface Props {
comparison: CollectionEntry<'comparisons'>;
@ -10,28 +15,22 @@ const { comparison } = Astro.props;
const { data } = comparison;
---
<a
href={`/comparisons/${comparison.id}`}
class="comparison-card group"
>
<a href={`/comparisons/${comparison.id}`} class="comparison-card group">
<div class="card-content">
<!-- Header: Icon, Badges, Type -->
<div class="flex items-start justify-between mb-4">
<div class="mb-4 flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="icon">{data.icon}</div>
<div>
<span class="type-badge">
{getTypeIcon(data.type)} {getTypeDisplayName(data.type)}
{getTypeIcon(data.type)}
{getTypeDisplayName(data.type)}
</span>
</div>
</div>
<div class="flex flex-col gap-2 items-end">
{data.featured && (
<span class="badge badge-primary">Featured</span>
)}
{data.trending && (
<span class="badge badge-trending">🔥 Trending</span>
)}
<div class="flex flex-col items-end gap-2">
{data.featured && <span class="badge badge-primary">Featured</span>}
{data.trending && <span class="badge badge-trending">🔥 Trending</span>}
</div>
</div>
@ -47,32 +46,64 @@ const { data } = comparison;
<!-- Pricing Winner -->
<div class="stat-item">
<div class="stat-label">💰 Pricing</div>
<div class={`stat-value ${data.comparisonTable.pricing.winner === 'picture' ? 'text-green-400' : 'text-gray-400'}`}>
{data.comparisonTable.pricing.winner === 'picture' ? '✓ Picture' : data.comparisonTable.pricing.winner === 'tie' ? 'Tie' : 'Competitor'}
<div
class={`stat-value ${data.comparisonTable.pricing.winner === 'picture' ? 'text-green-400' : 'text-gray-400'}`}
>
{
data.comparisonTable.pricing.winner === 'picture'
? '✓ Picture'
: data.comparisonTable.pricing.winner === 'tie'
? 'Tie'
: 'Competitor'
}
</div>
</div>
<!-- Speed Winner -->
<div class="stat-item">
<div class="stat-label">⚡ Speed</div>
<div class={`stat-value ${data.comparisonTable.speed.winner === 'picture' ? 'text-green-400' : 'text-gray-400'}`}>
{data.comparisonTable.speed.winner === 'picture' ? '✓ Picture' : data.comparisonTable.speed.winner === 'tie' ? 'Tie' : 'Competitor'}
<div
class={`stat-value ${data.comparisonTable.speed.winner === 'picture' ? 'text-green-400' : 'text-gray-400'}`}
>
{
data.comparisonTable.speed.winner === 'picture'
? '✓ Picture'
: data.comparisonTable.speed.winner === 'tie'
? 'Tie'
: 'Competitor'
}
</div>
</div>
<!-- Quality Winner -->
<div class="stat-item">
<div class="stat-label">🎨 Quality</div>
<div class={`stat-value ${data.comparisonTable.imageQuality.winner === 'picture' ? 'text-green-400' : 'text-gray-400'}`}>
{data.comparisonTable.imageQuality.winner === 'picture' ? '✓ Picture' : data.comparisonTable.imageQuality.winner === 'tie' ? 'Tie' : 'Competitor'}
<div
class={`stat-value ${data.comparisonTable.imageQuality.winner === 'picture' ? 'text-green-400' : 'text-gray-400'}`}
>
{
data.comparisonTable.imageQuality.winner === 'picture'
? '✓ Picture'
: data.comparisonTable.imageQuality.winner === 'tie'
? 'Tie'
: 'Competitor'
}
</div>
</div>
<!-- Ease of Use Winner -->
<div class="stat-item">
<div class="stat-label">🎯 Ease</div>
<div class={`stat-value ${data.comparisonTable.easeOfUse.winner === 'picture' ? 'text-green-400' : 'text-gray-400'}`}>
{data.comparisonTable.easeOfUse.winner === 'picture' ? '✓ Picture' : data.comparisonTable.easeOfUse.winner === 'tie' ? 'Tie' : 'Competitor'}
<div
class={`stat-value ${data.comparisonTable.easeOfUse.winner === 'picture' ? 'text-green-400' : 'text-gray-400'}`}
>
{
data.comparisonTable.easeOfUse.winner === 'picture'
? '✓ Picture'
: data.comparisonTable.easeOfUse.winner === 'tie'
? 'Tie'
: 'Competitor'
}
</div>
</div>
</div>
@ -84,17 +115,28 @@ const { data } = comparison;
</div>
<!-- Overall Winner Badge -->
{data.winnerBadge && (
<div class={`winner-badge ${getWinnerBadgeColor(data.winnerBadge)}`}>
{getWinnerBadgeText(data.winnerBadge)}
</div>
)}
{
data.winnerBadge && (
<div class={`winner-badge ${getWinnerBadgeColor(data.winnerBadge)}`}>
{getWinnerBadgeText(data.winnerBadge)}
</div>
)
}
<!-- CTA Arrow -->
<div class="cta-arrow">
<span>Read Full Comparison</span>
<svg class="w-5 h-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
<svg
class="h-5 w-5 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</div>
</div>
@ -102,13 +144,13 @@ const { data } = comparison;
<style>
.comparison-card {
@apply block bg-dark-elevated border border-dark-border rounded-2xl p-6
transition-all duration-300 hover:border-primary hover:shadow-xl
hover:shadow-primary/10 hover:-translate-y-1;
@apply block rounded-2xl border border-dark-border bg-dark-elevated p-6
transition-all duration-300 hover:-translate-y-1 hover:border-primary
hover:shadow-xl hover:shadow-primary/10;
}
.card-content {
@apply flex flex-col h-full;
@apply flex h-full flex-col;
}
.icon {
@ -116,35 +158,35 @@ const { data } = comparison;
}
.type-badge {
@apply px-2 py-1 bg-dark-bg border border-dark-border rounded-lg text-xs text-gray-400;
@apply rounded-lg border border-dark-border bg-dark-bg px-2 py-1 text-xs text-gray-400;
}
.badge {
@apply px-2 py-1 rounded-full text-xs font-medium;
@apply rounded-full px-2 py-1 text-xs font-medium;
}
.badge-primary {
@apply bg-primary/10 text-primary border border-primary/20;
@apply border border-primary/20 bg-primary/10 text-primary;
}
.badge-trending {
@apply bg-orange-500/10 text-orange-400 border border-orange-500/20;
@apply border border-orange-500/20 bg-orange-500/10 text-orange-400;
}
.title {
@apply text-xl font-bold text-white mb-2 group-hover:text-primary transition-colors;
@apply mb-2 text-xl font-bold text-white transition-colors group-hover:text-primary;
}
.competitor {
@apply text-sm text-gray-500 mb-3;
@apply mb-3 text-sm text-gray-500;
}
.description {
@apply text-gray-400 text-sm leading-relaxed mb-4;
@apply mb-4 text-sm leading-relaxed text-gray-400;
}
.stats-grid {
@apply grid grid-cols-2 gap-3 mb-4 pb-4 border-b border-dark-border;
@apply mb-4 grid grid-cols-2 gap-3 border-b border-dark-border pb-4;
}
.stat-item {
@ -152,7 +194,7 @@ const { data } = comparison;
}
.stat-label {
@apply text-xs text-gray-500 mb-1;
@apply mb-1 text-xs text-gray-500;
}
.stat-value {
@ -160,22 +202,22 @@ const { data } = comparison;
}
.verdict {
@apply bg-dark-bg border border-dark-border rounded-lg p-3 mb-4;
@apply mb-4 rounded-lg border border-dark-border bg-dark-bg p-3;
}
.verdict-label {
@apply text-xs font-semibold text-primary mb-1;
@apply mb-1 text-xs font-semibold text-primary;
}
.verdict-text {
@apply text-sm text-gray-300 leading-relaxed;
@apply text-sm leading-relaxed text-gray-300;
}
.winner-badge {
@apply text-center py-2 px-4 rounded-lg font-semibold text-sm mb-4;
@apply mb-4 rounded-lg px-4 py-2 text-center text-sm font-semibold;
}
.cta-arrow {
@apply flex items-center justify-between text-sm font-medium text-primary mt-auto;
@apply mt-auto flex items-center justify-between text-sm font-medium text-primary;
}
</style>

View file

@ -40,34 +40,37 @@ const comparisonSchema = {
};
// Add Product comparison schema for versus comparisons
const productComparisonSchema = data.type === 'versus' ? {
'@context': 'https://schema.org',
'@type': 'ComparisonTable',
about: [
{
'@type': 'Product',
name: 'Picture AI Image Generator',
description: 'Fast, affordable AI image generation with FLUX and Stable Diffusion',
offers: {
'@type': 'Offer',
price: data.comparisonTable.pricing.picture,
priceCurrency: 'USD',
},
},
{
'@type': 'Product',
name: data.competitor,
description: `${data.competitor} AI image generator`,
...(data.competitorPricing && {
offers: {
'@type': 'Offer',
price: data.competitorPricing,
priceCurrency: 'USD',
},
}),
},
],
} : null;
const productComparisonSchema =
data.type === 'versus'
? {
'@context': 'https://schema.org',
'@type': 'ComparisonTable',
about: [
{
'@type': 'Product',
name: 'Picture AI Image Generator',
description: 'Fast, affordable AI image generation with FLUX and Stable Diffusion',
offers: {
'@type': 'Offer',
price: data.comparisonTable.pricing.picture,
priceCurrency: 'USD',
},
},
{
'@type': 'Product',
name: data.competitor,
description: `${data.competitor} AI image generator`,
...(data.competitorPricing && {
offers: {
'@type': 'Offer',
price: data.competitorPricing,
priceCurrency: 'USD',
},
}),
},
],
}
: null;
// BreadcrumbList schema for better navigation
const breadcrumbSchema = {
@ -96,43 +99,45 @@ const breadcrumbSchema = {
};
// HowTo schema for "best of" roundup articles
const howToSchema = data.type === 'roundup' ? {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: data.title,
description: data.description,
step: [
{
'@type': 'HowToStep',
name: 'Compare Features',
text: 'Review the feature comparison table to understand capabilities',
},
{
'@type': 'HowToStep',
name: 'Compare Pricing',
text: 'Evaluate pricing models and value for your use case',
},
{
'@type': 'HowToStep',
name: 'Choose Best Tool',
text: 'Select the AI image generator that best fits your needs',
},
],
} : null;
const howToSchema =
data.type === 'roundup'
? {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: data.title,
description: data.description,
step: [
{
'@type': 'HowToStep',
name: 'Compare Features',
text: 'Review the feature comparison table to understand capabilities',
},
{
'@type': 'HowToStep',
name: 'Compare Pricing',
text: 'Evaluate pricing models and value for your use case',
},
{
'@type': 'HowToStep',
name: 'Choose Best Tool',
text: 'Select the AI image generator that best fits your needs',
},
],
}
: null;
---
<!-- Main Article Schema -->
<script type="application/ld+json" set:html={JSON.stringify(comparisonSchema)} />
<!-- Product Comparison Schema (for versus comparisons) -->
{productComparisonSchema && (
<script type="application/ld+json" set:html={JSON.stringify(productComparisonSchema)} />
)}
<!-- Product Comparison Schema (for versus comparisons) -->{
productComparisonSchema && (
<script type="application/ld+json" set:html={JSON.stringify(productComparisonSchema)} />
)
}
<!-- Breadcrumb Schema -->
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbSchema)} />
<!-- HowTo Schema (for roundup articles) -->
{howToSchema && (
<script type="application/ld+json" set:html={JSON.stringify(howToSchema)} />
)}
{howToSchema && <script type="application/ld+json" set:html={JSON.stringify(howToSchema)} />}

View file

@ -26,8 +26,7 @@ const { Content } = await faq.render();
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
stroke-linejoin="round"></path>
</svg>
</summary>
<div class="faq-answer">
@ -37,7 +36,7 @@ const { Content } = await faq.render();
<style>
.faq-item {
@apply bg-dark-elevated border border-dark-border rounded-xl overflow-hidden transition-all duration-200;
@apply overflow-hidden rounded-xl border border-dark-border bg-dark-elevated transition-all duration-200;
}
.faq-item:hover {
@ -45,8 +44,8 @@ const { Content } = await faq.render();
}
.faq-question {
@apply flex items-center justify-between w-full p-6 cursor-pointer select-none text-left;
@apply font-medium text-lg text-gray-100;
@apply flex w-full cursor-pointer select-none items-center justify-between p-6 text-left;
@apply text-lg font-medium text-gray-100;
}
.faq-question:hover {
@ -54,7 +53,7 @@ const { Content } = await faq.render();
}
.chevron {
@apply flex-shrink-0 ml-4 text-gray-400 transition-transform duration-200;
@apply ml-4 flex-shrink-0 text-gray-400 transition-transform duration-200;
}
.group[open] .chevron {
@ -62,16 +61,16 @@ const { Content } = await faq.render();
}
.faq-answer {
@apply px-6 pb-6 text-gray-300 prose prose-invert max-w-none;
@apply prose prose-invert max-w-none px-6 pb-6 text-gray-300;
}
/* Markdown content styling */
.faq-answer :global(h2) {
@apply text-xl font-semibold text-gray-100 mt-6 mb-3;
@apply mb-3 mt-6 text-xl font-semibold text-gray-100;
}
.faq-answer :global(h3) {
@apply text-lg font-semibold text-gray-200 mt-4 mb-2;
@apply mb-2 mt-4 text-lg font-semibold text-gray-200;
}
.faq-answer :global(p) {
@ -80,7 +79,7 @@ const { Content } = await faq.render();
.faq-answer :global(ul),
.faq-answer :global(ol) {
@apply mb-4 pl-6 space-y-2;
@apply mb-4 space-y-2 pl-6;
}
.faq-answer :global(li) {
@ -88,7 +87,7 @@ const { Content } = await faq.render();
}
.faq-answer :global(strong) {
@apply text-gray-100 font-semibold;
@apply font-semibold text-gray-100;
}
.faq-answer :global(a) {
@ -96,26 +95,26 @@ const { Content } = await faq.render();
}
.faq-answer :global(code) {
@apply bg-dark-bg px-1.5 py-0.5 rounded text-sm font-mono text-primary;
@apply rounded bg-dark-bg px-1.5 py-0.5 font-mono text-sm text-primary;
}
.faq-answer :global(pre) {
@apply bg-dark-bg p-4 rounded-lg overflow-x-auto my-4;
@apply my-4 overflow-x-auto rounded-lg bg-dark-bg p-4;
}
.faq-answer :global(table) {
@apply w-full border-collapse my-4;
@apply my-4 w-full border-collapse;
}
.faq-answer :global(th) {
@apply bg-dark-bg text-left p-3 font-semibold text-gray-100 border border-dark-border;
@apply border border-dark-border bg-dark-bg p-3 text-left font-semibold text-gray-100;
}
.faq-answer :global(td) {
@apply p-3 border border-dark-border;
@apply border border-dark-border p-3;
}
.faq-answer :global(blockquote) {
@apply border-l-4 border-primary pl-4 italic text-gray-400 my-4;
@apply my-4 border-l-4 border-primary pl-4 italic text-gray-400;
}
</style>

View file

@ -8,88 +8,123 @@ interface Props {
}
const { feature } = Astro.props;
const { title, description, icon, coverImage, category, featured, available, comingSoon } = feature.data;
const { title, description, icon, coverImage, category, featured, available, comingSoon } =
feature.data;
// Remove language prefix from slug (e.g., "en/cross-platform-apps" -> "cross-platform-apps")
const slug = feature.slug.split('/').pop() || feature.slug;
---
<article class="group relative bg-dark-elevated border border-dark-border rounded-2xl overflow-hidden hover:border-primary/50 transition-all duration-300 hover:scale-[1.02]">
<article
class="group relative overflow-hidden rounded-2xl border border-dark-border bg-dark-elevated transition-all duration-300 hover:scale-[1.02] hover:border-primary/50"
>
<!-- Featured Badge -->
{featured && (
<div class="absolute top-4 right-4 z-20 px-3 py-1 bg-primary text-white text-xs font-bold rounded-full uppercase">
{t('features.featured')}
</div>
)}
{
featured && (
<div class="absolute right-4 top-4 z-20 rounded-full bg-primary px-3 py-1 text-xs font-bold uppercase text-white">
{t('features.featured')}
</div>
)
}
<!-- Coming Soon Badge -->
{comingSoon && (
<div class="absolute top-4 left-4 z-20 px-3 py-1 bg-secondary text-white text-xs font-bold rounded-full">
{t('features.coming_soon')}
</div>
)}
{
comingSoon && (
<div class="absolute left-4 top-4 z-20 rounded-full bg-secondary px-3 py-1 text-xs font-bold text-white">
{t('features.coming_soon')}
</div>
)
}
<!-- Cover Image -->
<a href={localizePath(`/features/${slug}`)} class="block relative aspect-video overflow-hidden bg-gradient-to-br from-primary/20 to-secondary/20">
<a
href={localizePath(`/features/${slug}`)}
class="relative block aspect-video overflow-hidden bg-gradient-to-br from-primary/20 to-secondary/20"
>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-8xl">{icon}</span>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-dark-bg/90 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div
class="absolute inset-0 bg-gradient-to-t from-dark-bg/90 to-transparent opacity-0 transition-opacity group-hover:opacity-100"
>
</div>
</a>
<!-- Content -->
<div class="p-6">
<!-- Category Badge -->
<div class="mb-3">
<span class="px-3 py-1 bg-dark-surface text-primary text-sm font-medium rounded-full capitalize">
<span
class="rounded-full bg-dark-surface px-3 py-1 text-sm font-medium capitalize text-primary"
>
{t(`features.categories.${category}`)}
</span>
</div>
<!-- Title -->
<a href={localizePath(`/features/${slug}`)} class="block mb-3">
<h3 class="text-xl font-bold text-white group-hover:text-primary transition-colors line-clamp-2">
<a href={localizePath(`/features/${slug}`)} class="mb-3 block">
<h3
class="line-clamp-2 text-xl font-bold text-white transition-colors group-hover:text-primary"
>
{title}
</h3>
</a>
<!-- Description -->
<p class="text-gray-300 mb-4 line-clamp-3">
<p class="mb-4 line-clamp-3 text-gray-300">
{description}
</p>
<!-- Benefits -->
{feature.data.benefits && feature.data.benefits.length > 0 && (
<ul class="space-y-2 mb-4">
{feature.data.benefits.slice(0, 3).map(benefit => (
<li class="flex items-start gap-2 text-sm text-gray-400">
<svg class="w-4 h-4 text-primary mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
<span class="line-clamp-1">{benefit}</span>
</li>
))}
</ul>
)}
{
feature.data.benefits && feature.data.benefits.length > 0 && (
<ul class="mb-4 space-y-2">
{feature.data.benefits.slice(0, 3).map((benefit) => (
<li class="flex items-start gap-2 text-sm text-gray-400">
<svg
class="mt-0.5 h-4 w-4 flex-shrink-0 text-primary"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
<span class="line-clamp-1">{benefit}</span>
</li>
))}
</ul>
)
}
<!-- CTA -->
<a
href={localizePath(`/features/${slug}`)}
class="inline-flex items-center gap-2 text-primary hover:text-primary-400 transition-colors font-medium"
class="inline-flex items-center gap-2 font-medium text-primary transition-colors hover:text-primary-400"
>
{t('features.learn_more')}
<svg class="w-4 h-4 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
<svg
class="h-4 w-4 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"
></path>
</svg>
</a>
<!-- Not Available Overlay -->
{!available && (
<div class="absolute inset-0 bg-dark-bg/80 backdrop-blur-sm flex items-center justify-center rounded-2xl">
<span class="px-4 py-2 bg-dark-elevated border border-dark-border rounded-lg text-gray-300 font-medium">
{comingSoon ? t('features.coming_soon') : t('features.not_available')}
</span>
</div>
)}
{
!available && (
<div class="absolute inset-0 flex items-center justify-center rounded-2xl bg-dark-bg/80 backdrop-blur-sm">
<span class="rounded-lg border border-dark-border bg-dark-elevated px-4 py-2 font-medium text-gray-300">
{comingSoon ? t('features.coming_soon') : t('features.not_available')}
</span>
</div>
)
}
</div>
</article>

View file

@ -118,11 +118,7 @@ const { data } = image;
data.creator && (
<div class="mt-3 flex items-center gap-2 border-t border-gray-100 pt-3 dark:border-gray-700">
{data.creator.avatar && (
<img
src={data.creator.avatar}
alt={data.creator.name}
class="h-6 w-6 rounded-full"
/>
<img src={data.creator.avatar} alt={data.creator.name} class="h-6 w-6 rounded-full" />
)}
<span class="text-xs text-gray-600 dark:text-gray-400">{data.creator.name}</span>
</div>

View file

@ -36,9 +36,7 @@ const categoryIcons: Record<string, string> = {
<!-- Category Filters -->
<div class="mb-6">
<div class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">
📂 Categories
</div>
<div class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">📂 Categories</div>
<div class="flex flex-wrap gap-2">
<button
data-filter="all"
@ -67,7 +65,7 @@ const categoryIcons: Record<string, string> = {
</label>
<select
id="sort"
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:w-auto"
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 sm:w-auto dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="likes">Most Liked</option>
<option value="views">Most Viewed</option>
@ -200,13 +198,25 @@ const categoryIcons: Record<string, string> = {
viewToggles.forEach((toggle) => toggle?.classList.remove('active'));
gridView.classList.add('active');
galleryGrid?.classList.remove('list-view');
galleryGrid?.classList.add('grid', 'grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4');
galleryGrid?.classList.add(
'grid',
'grid-cols-1',
'sm:grid-cols-2',
'lg:grid-cols-3',
'xl:grid-cols-4'
);
});
listView?.addEventListener('click', () => {
viewToggles.forEach((toggle) => toggle?.classList.remove('active'));
listView.classList.add('active');
galleryGrid?.classList.remove('grid', 'grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4');
galleryGrid?.classList.remove(
'grid',
'grid-cols-1',
'sm:grid-cols-2',
'lg:grid-cols-3',
'xl:grid-cols-4'
);
galleryGrid?.classList.add('list-view');
});
});

View file

@ -24,11 +24,9 @@ const gridClasses = {
{
images.length === 0 && (
<div class="rounded-2xl border-2 border-dashed border-gray-300 bg-gray-50 px-8 py-16 text-center dark:border-gray-700 dark:bg-gray-800">
<div class="text-6xl mb-4">🖼️</div>
<div class="mb-4 text-6xl">🖼️</div>
<h3 class="mb-2 text-xl font-bold text-gray-900 dark:text-white">No Images Found</h3>
<p class="text-gray-600 dark:text-gray-400">
Try adjusting your filters or search query.
</p>
<p class="text-gray-600 dark:text-gray-400">Try adjusting your filters or search query.</p>
</div>
)
}

View file

@ -17,16 +17,16 @@ const {
{
templates.length > 0 && (
<section class="py-16 bg-white">
<section class="bg-white py-16">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between mb-8">
<div class="mb-8 flex items-center justify-between">
<div>
<h2 class="text-3xl font-bold text-gray-900">{title}</h2>
<p class="text-gray-600 mt-2">{description}</p>
<p class="mt-2 text-gray-600">{description}</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div class="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
{templates.map((template) => (
<TemplateCard template={template} />
))}

View file

@ -8,8 +8,8 @@ export interface Props {
const { template } = Astro.props;
---
<div class="bg-white rounded-xl shadow-lg p-8 border border-gray-200">
<h2 class="text-2xl font-bold text-gray-900 mb-6">🛠️ Build Your Prompt</h2>
<div class="rounded-xl border border-gray-200 bg-white p-8 shadow-lg">
<h2 class="mb-6 text-2xl font-bold text-gray-900">🛠️ Build Your Prompt</h2>
<form id="prompt-form" class="space-y-6">
{
@ -17,13 +17,13 @@ const { template } = Astro.props;
<div>
<label
for={`var-${variable.name}`}
class="block text-sm font-semibold text-gray-900 mb-2"
class="mb-2 block text-sm font-semibold text-gray-900"
>
{variable.description}
{variable.required ? (
<span class="text-red-500">*</span>
) : (
<span class="text-gray-500 font-normal">(optional)</span>
<span class="font-normal text-gray-500">(optional)</span>
)}
</label>
<input
@ -32,7 +32,7 @@ const { template } = Astro.props;
name={variable.name}
placeholder={variable.placeholder}
required={variable.required}
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
class="w-full rounded-lg border border-gray-300 px-4 py-3 focus:border-transparent focus:ring-2 focus:ring-purple-500"
/>
</div>
))
@ -41,7 +41,7 @@ const { template } = Astro.props;
<button
type="button"
id="generate-prompt-btn"
class="w-full bg-purple-600 hover:bg-purple-700 text-white font-semibold py-4 rounded-lg transition-colors"
class="w-full rounded-lg bg-purple-600 py-4 font-semibold text-white transition-colors hover:bg-purple-700"
>
Generate Prompt
</button>
@ -49,13 +49,13 @@ const { template } = Astro.props;
<!-- Generated Prompt Display -->
<div id="generated-prompt-container" class="mt-6 hidden">
<div class="flex items-center justify-between mb-3">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900">Your Generated Prompt</h3>
<button
id="copy-prompt-btn"
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium rounded-lg transition-colors flex items-center gap-2"
class="flex items-center gap-2 rounded-lg bg-gray-100 px-4 py-2 font-medium text-gray-700 transition-colors hover:bg-gray-200"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -68,18 +68,18 @@ const { template } = Astro.props;
</div>
<div
id="generated-prompt"
class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-gray-800 font-mono text-sm"
class="rounded-lg border border-gray-200 bg-gray-50 p-4 font-mono text-sm text-gray-800"
>
</div>
<!-- CTA to app -->
<div class="mt-4 p-4 bg-purple-50 border border-purple-200 rounded-lg">
<p class="text-sm text-gray-700 mb-3">
<div class="mt-4 rounded-lg border border-purple-200 bg-purple-50 p-4">
<p class="mb-3 text-sm text-gray-700">
Ready to generate your image? Use this prompt in Picture!
</p>
<a
href="/app"
class="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors"
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-6 py-3 font-semibold text-white transition-colors hover:bg-purple-700"
>
Open Picture App →
</a>

View file

@ -18,7 +18,7 @@ const slug = template.slug;
>
<div class={`p-${compact ? '4' : '6'}`}>
<!-- Header -->
<div class="flex items-start justify-between mb-3">
<div class="mb-3 flex items-start justify-between">
<div class="flex items-center gap-3">
<span class={`text-${compact ? '2xl' : '3xl'}`}>{template.data.icon}</span>
<div>
@ -29,9 +29,9 @@ const slug = template.slug;
</h3>
{
!compact && (
<div class="flex items-center gap-2 mt-1">
<div class="mt-1 flex items-center gap-2">
<span
class={`px-2 py-0.5 text-xs font-semibold rounded-full ${
class={`rounded-full px-2 py-0.5 text-xs font-semibold ${
template.data.difficulty === 'beginner'
? 'bg-green-100 text-green-800'
: template.data.difficulty === 'intermediate'
@ -51,21 +51,21 @@ const slug = template.slug;
<div class="flex flex-col gap-1">
{
template.data.featured && (
<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
<span class="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-800">
</span>
)
}
{
template.data.popular && (
<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-800">
Popular
</span>
)
}
{
template.data.trending && (
<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-100 text-orange-800">
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-semibold text-orange-800">
🔥
</span>
)
@ -74,33 +74,27 @@ const slug = template.slug;
</div>
<!-- Description -->
{
!compact && (
<p class="text-sm text-gray-600 mb-4 line-clamp-2">{template.data.description}</p>
)
}
{!compact && <p class="mb-4 line-clamp-2 text-sm text-gray-600">{template.data.description}</p>}
<!-- Category & Tags -->
{
!compact && (
<div class="flex flex-wrap gap-2 mb-4">
<span class="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full">
<div class="mb-4 flex flex-wrap gap-2">
<span class="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700">
{formatCategoryName(template.data.category)}
</span>
{template.data.tags.slice(0, 2).map((tag) => (
<span class="px-3 py-1 bg-gray-50 text-gray-600 text-sm rounded-full">
#{tag}
</span>
<span class="rounded-full bg-gray-50 px-3 py-1 text-sm text-gray-600">#{tag}</span>
))}
</div>
)
}
<!-- Stats -->
<div class="flex items-center justify-between pt-4 border-t border-gray-100">
<div class="flex items-center justify-between border-t border-gray-100 pt-4">
<div class="flex items-center gap-4 text-sm text-gray-500">
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -111,7 +105,7 @@ const slug = template.slug;
{template.data.likes}
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -129,7 +123,7 @@ const slug = template.slug;
</div>
<div class="flex items-center gap-1 text-yellow-500">
<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24">
<svg class="h-4 w-4 fill-current" viewBox="0 0 24 24">
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
></path>
@ -141,12 +135,10 @@ const slug = template.slug;
<!-- Model -->
{
!compact && (
<div class="mt-4 pt-4 border-t border-gray-100">
<div class="mt-4 border-t border-gray-100 pt-4">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500">Recommended:</span>
<span class="font-semibold text-gray-700">
{template.data.recommendedModel}
</span>
<span class="font-semibold text-gray-700">{template.data.recommendedModel}</span>
</div>
</div>
)

View file

@ -17,17 +17,17 @@ const { categories, difficulties, models, stats } = Astro.props;
import { formatCategoryName } from '../../utils/promptTemplates';
---
<section class="bg-gray-50 border-b sticky top-0 z-10">
<section class="sticky top-0 z-10 border-b bg-gray-50">
<div class="container mx-auto px-4 py-6">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex flex-wrap items-center gap-4">
<!-- Category Filter -->
<div class="flex-1 min-w-[200px]">
<label for="category-filter" class="block text-sm font-medium text-gray-700 mb-1">
<div class="min-w-[200px] flex-1">
<label for="category-filter" class="mb-1 block text-sm font-medium text-gray-700">
Category
</label>
<select
id="category-filter"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-purple-500"
>
<option value="">All Categories</option>
{
@ -41,19 +41,20 @@ import { formatCategoryName } from '../../utils/promptTemplates';
</div>
<!-- Difficulty Filter -->
<div class="flex-1 min-w-[200px]">
<label for="difficulty-filter" class="block text-sm font-medium text-gray-700 mb-1">
<div class="min-w-[200px] flex-1">
<label for="difficulty-filter" class="mb-1 block text-sm font-medium text-gray-700">
Difficulty
</label>
<select
id="difficulty-filter"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-purple-500"
>
<option value="">All Levels</option>
{
difficulties.map((diff) => (
<option value={diff}>
{diff.charAt(0).toUpperCase() + diff.slice(1)} ({stats.byDifficulty[diff as keyof typeof stats.byDifficulty]})
{diff.charAt(0).toUpperCase() + diff.slice(1)} (
{stats.byDifficulty[diff as keyof typeof stats.byDifficulty]})
</option>
))
}
@ -61,13 +62,13 @@ import { formatCategoryName } from '../../utils/promptTemplates';
</div>
<!-- Model Filter -->
<div class="flex-1 min-w-[200px]">
<label for="model-filter" class="block text-sm font-medium text-gray-700 mb-1">
<div class="min-w-[200px] flex-1">
<label for="model-filter" class="mb-1 block text-sm font-medium text-gray-700">
Model
</label>
<select
id="model-filter"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-purple-500"
>
<option value="">All Models</option>
{models.map((model) => <option value={model}>{model}</option>)}
@ -75,13 +76,13 @@ import { formatCategoryName } from '../../utils/promptTemplates';
</div>
<!-- Sort -->
<div class="flex-1 min-w-[200px]">
<label for="sort-select" class="block text-sm font-medium text-gray-700 mb-1">
<div class="min-w-[200px] flex-1">
<label for="sort-select" class="mb-1 block text-sm font-medium text-gray-700">
Sort By
</label>
<select
id="sort-select"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-purple-500"
>
<option value="popular">Most Popular</option>
<option value="recent">Most Recent</option>
@ -92,7 +93,7 @@ import { formatCategoryName } from '../../utils/promptTemplates';
</div>
<!-- Active Filters -->
<div id="active-filters" class="flex flex-wrap gap-2 mt-4 hidden"></div>
<div id="active-filters" class="mt-4 flex hidden flex-wrap gap-2"></div>
</div>
</section>

View file

@ -2,8 +2,8 @@
import type { CollectionEntry } from 'astro:content';
interface Props {
testimonial: CollectionEntry<'testimonials'>;
featured?: boolean;
testimonial: CollectionEntry<'testimonials'>;
featured?: boolean;
}
const { testimonial, featured = false } = Astro.props;
@ -14,80 +14,97 @@ const { Content } = await testimonial.render();
const stars = Array.from({ length: 5 }, (_, i) => i < rating);
---
<div class={`
<div
class={`
bg-dark-elevated border border-dark-border rounded-2xl p-6
${featured ? 'ring-2 ring-primary/30' : ''}
hover:border-primary/50 transition-all duration-300
${featured ? 'shadow-xl shadow-primary/20' : 'shadow-md'}
`}>
<!-- Header -->
<div class="flex items-start gap-4 mb-4">
<!-- Avatar -->
<div class="flex-shrink-0">
{avatar ? (
<img
src={avatar}
alt={name}
class="w-16 h-16 rounded-full object-cover border-2 border-primary/30"
/>
) : (
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-2xl text-white font-bold">
{name.charAt(0)}
</div>
)}
</div>
`}
>
<!-- Header -->
<div class="mb-4 flex items-start gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
{
avatar ? (
<img
src={avatar}
alt={name}
class="h-16 w-16 rounded-full border-2 border-primary/30 object-cover"
/>
) : (
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-primary to-secondary text-2xl font-bold text-white">
{name.charAt(0)}
</div>
)
}
</div>
<!-- Name & Role -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="text-lg font-semibold text-white truncate">{name}</h3>
{verified && (
<svg class="w-5 h-5 text-primary flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
)}
</div>
<p class="text-sm text-gray-400">
{role}{company ? ` • ${company}` : ''}
</p>
<!-- Name & Role -->
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<h3 class="truncate text-lg font-semibold text-white">{name}</h3>
{
verified && (
<svg class="h-5 w-5 flex-shrink-0 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
)
}
</div>
<p class="text-sm text-gray-400">
{role}{company ? ` • ${company}` : ''}
</p>
<!-- Star Rating -->
<div class="flex items-center gap-1 mt-2">
{stars.map(filled => (
<svg
class={`w-4 h-4 ${filled ? 'text-yellow-400' : 'text-gray-600'}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
))}
<span class="text-sm text-gray-400 ml-1">{rating}.0</span>
</div>
</div>
</div>
<!-- Star Rating -->
<div class="mt-2 flex items-center gap-1">
{
stars.map((filled) => (
<svg
class={`h-4 w-4 ${filled ? 'text-yellow-400' : 'text-gray-600'}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))
}
<span class="ml-1 text-sm text-gray-400">{rating}.0</span>
</div>
</div>
</div>
<!-- Content -->
<div class="prose prose-invert prose-sm max-w-none">
<Content />
</div>
<!-- Content -->
<div class="prose prose-sm prose-invert max-w-none">
<Content />
</div>
<!-- Category Badge (Optional) -->
{featured && (
<div class="mt-4 pt-4 border-t border-dark-border">
<span class="inline-block px-3 py-1 text-xs font-medium bg-primary/20 text-primary rounded-full">
{category.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
</span>
</div>
)}
<!-- Category Badge (Optional) -->
{
featured && (
<div class="mt-4 border-t border-dark-border pt-4">
<span class="inline-block rounded-full bg-primary/20 px-3 py-1 text-xs font-medium text-primary">
{category
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')}
</span>
</div>
)
}
</div>
<style>
.prose p {
@apply text-gray-300 leading-relaxed;
}
.prose p {
@apply leading-relaxed text-gray-300;
}
.prose strong {
@apply text-white font-semibold;
}
.prose strong {
@apply font-semibold text-white;
}
</style>

View file

@ -11,31 +11,40 @@ const { steps } = Astro.props;
<div class="step-header">
<h3 class="step-title">Tutorial Steps</h3>
<button id="toggle-steps" class="toggle-btn">
<svg id="chevron-icon" class="w-5 h-5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
<svg
id="chevron-icon"
class="h-5 w-5 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
</div>
<div id="steps-list" class="steps-list">
<ol class="steps">
{steps.map((step, index) => (
<li class="step-item" data-step={index}>
<div class="step-number">{index + 1}</div>
<div class="step-content">
<span class="step-name">{step.title}</span>
{step.duration && <span class="step-duration">{step.duration}</span>}
</div>
<div class="step-check hidden">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
</li>
))}
{
steps.map((step, index) => (
<li class="step-item" data-step={index}>
<div class="step-number">{index + 1}</div>
<div class="step-content">
<span class="step-name">{step.title}</span>
{step.duration && <span class="step-duration">{step.duration}</span>}
</div>
<div class="step-check hidden">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
</li>
))
}
</ol>
</div>
</div>
@ -73,7 +82,7 @@ const { steps } = Astro.props;
if (scrollPos >= headingPos && scrollPos < nextHeadingPos) {
// Remove active from all steps
stepItems.forEach(item => item.classList.remove('active'));
stepItems.forEach((item) => item.classList.remove('active'));
// Add active to current step
const currentStep = document.querySelector(`[data-step="${index}"]`);
@ -88,11 +97,11 @@ const { steps } = Astro.props;
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.5
threshold: 0.5,
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const heading = entry.target;
const index = Array.from(headings).indexOf(heading as Element);
@ -110,7 +119,7 @@ const { steps } = Astro.props;
}, observerOptions);
// Observe all h2 headings
headings.forEach(heading => observer.observe(heading));
headings.forEach((heading) => observer.observe(heading));
// Update on scroll
window.addEventListener('scroll', updateActiveStep);
@ -133,11 +142,11 @@ const { steps } = Astro.props;
}
.step-indicator-content {
@apply bg-dark-elevated border border-dark-border rounded-xl overflow-hidden shadow-lg;
@apply overflow-hidden rounded-xl border border-dark-border bg-dark-elevated shadow-lg;
}
.step-header {
@apply flex items-center justify-between p-4 border-b border-dark-border;
@apply flex items-center justify-between border-b border-dark-border p-4;
}
.step-title {
@ -145,7 +154,7 @@ const { steps } = Astro.props;
}
.toggle-btn {
@apply p-2 hover:bg-dark-bg rounded-lg transition text-gray-400 hover:text-white;
@apply rounded-lg p-2 text-gray-400 transition hover:bg-dark-bg hover:text-white;
}
.steps-list {
@ -157,16 +166,16 @@ const { steps } = Astro.props;
}
.steps {
@apply p-4 space-y-2;
@apply space-y-2 p-4;
}
.step-item {
@apply flex items-center gap-3 p-3 rounded-lg cursor-pointer
@apply flex cursor-pointer items-center gap-3 rounded-lg p-3
transition-all hover:bg-dark-bg;
}
.step-item.active {
@apply bg-primary/10 border border-primary/20;
@apply border border-primary/20 bg-primary/10;
}
.step-item.completed .step-number {
@ -174,8 +183,8 @@ const { steps } = Astro.props;
}
.step-number {
@apply w-8 h-8 flex items-center justify-center rounded-full
bg-dark-bg text-gray-400 text-sm font-medium flex-shrink-0;
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center
rounded-full bg-dark-bg text-sm font-medium text-gray-400;
}
.step-item.active .step-number {
@ -187,7 +196,7 @@ const { steps } = Astro.props;
}
.step-name {
@apply block text-sm text-gray-300 font-medium;
@apply block text-sm font-medium text-gray-300;
}
.step-item.active .step-name {
@ -195,11 +204,11 @@ const { steps } = Astro.props;
}
.step-duration {
@apply block text-xs text-gray-500 mt-0.5;
@apply mt-0.5 block text-xs text-gray-500;
}
.step-check {
@apply text-green-500 flex-shrink-0;
@apply flex-shrink-0 text-green-500;
}
/* Custom scrollbar */
@ -212,7 +221,7 @@ const { steps } = Astro.props;
}
.steps-list::-webkit-scrollbar-thumb {
@apply bg-dark-border rounded-full;
@apply rounded-full bg-dark-border;
}
.steps-list::-webkit-scrollbar-thumb:hover {

Some files were not shown because too many files have changed in this diff Show more