mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 19:06:42 +02:00
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:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"expo": {}
|
||||
}
|
||||
"expo": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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% {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)} />}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue