refactor(picture): remove Supabase dependency, migrate to NestJS backend

- Backend: Replace Supabase storage with S3/local hybrid StorageService
- Backend: Add rate-limits endpoint to ProfileController
- Mobile: Update RateLimitIndicator to use backend API
- Mobile: Remove @supabase/supabase-js dependency
- Shared: Remove queue.ts and supabase.ts (no longer needed)
- Update environment configuration for S3 storage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-01 14:22:49 +01:00
parent a09c389ede
commit 51edd52241
25 changed files with 1631 additions and 990 deletions

View file

@ -119,9 +119,21 @@ MANADECK_SUPABASE_ANON_KEY=your-supabase-anon-key
# PICTURE PROJECT
# ============================================
PICTURE_BACKEND_PORT=3003
PICTURE_BACKEND_URL=http://localhost:3003
PICTURE_SUPABASE_URL=https://your-picture-project.supabase.co
PICTURE_SUPABASE_ANON_KEY=your-supabase-anon-key
PICTURE_DATABASE_URL=postgresql://picture:picturepassword@localhost:5434/picture
# Storage Configuration (local for dev, s3 for production with Hetzner Object Storage)
PICTURE_STORAGE_MODE=local
PICTURE_LOCAL_STORAGE_PATH=./uploads
# S3/Hetzner Object Storage (for production)
# PICTURE_S3_ENDPOINT=fsn1.your-objectstorage.com
# PICTURE_S3_REGION=eu-central-1
# PICTURE_S3_ACCESS_KEY=your-access-key
# PICTURE_S3_SECRET_KEY=your-secret-key
# PICTURE_S3_BUCKET=picture-uploads
# PICTURE_STORAGE_PUBLIC_URL=https://picture-uploads.fsn1.your-objectstorage.com
# OAuth (optional - leave empty to disable)
PICTURE_GOOGLE_CLIENT_ID=

View file

@ -1,6 +1,7 @@
# Server
PORT=3003
NODE_ENV=development
BACKEND_URL=http://localhost:3003
# Database
DATABASE_URL=postgresql://picture:password@localhost:5432/picture
@ -8,9 +9,20 @@ DATABASE_URL=postgresql://picture:password@localhost:5432/picture
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001
# Supabase Storage (Service Role for Backend)
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
# Storage Configuration
# Options: 'local' (default) or 's3' (for Hetzner Object Storage)
STORAGE_MODE=local
# Local Storage (for development)
LOCAL_STORAGE_PATH=./uploads
# S3/Hetzner Object Storage (for production)
# S3_ENDPOINT=fsn1.your-objectstorage.com
# S3_REGION=eu-central-1
# S3_ACCESS_KEY=your-access-key
# S3_SECRET_KEY=your-secret-key
# S3_BUCKET=picture-uploads
# STORAGE_PUBLIC_URL=https://picture-uploads.fsn1.your-objectstorage.com
# Replicate API
REPLICATE_API_TOKEN=r8_xxx

View file

@ -18,12 +18,12 @@
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@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",

View file

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { BoardController } from './board.controller';
import { BoardService } from './board.service';
import { UploadModule } from '../upload/upload.module';
@Module({
imports: [UploadModule],
controllers: [BoardController],
providers: [BoardService],
exports: [BoardService],

View file

@ -1,11 +1,10 @@
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';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { boards, boardItems, type Board } from '../db/schema';
import { CreateBoardDto, UpdateBoardDto, GetBoardsQueryDto } from './dto/board.dto';
import { StorageService } from '../upload/storage.service';
export interface BoardWithCount extends Board {
itemCount: number;
@ -14,18 +13,11 @@ export interface BoardWithCount extends Board {
@Injectable()
export class BoardService {
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);
}
}
private readonly storageService: StorageService
) {}
async getBoards(userId: string, query: GetBoardsQueryDto): Promise<BoardWithCount[]> {
try {
@ -266,35 +258,14 @@ export class BoardService {
try {
await this.verifyOwnership(id, userId);
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');
// 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;
}
// Get public URL
const { data: urlData } = this.supabase.storage.from('user-uploads').getPublicUrl(filename);
// Upload thumbnail using StorageService
const thumbnailUrl = await this.storageService.uploadBoardThumbnail(id, dataUrl);
// Update board with thumbnail URL
const result = await this.db
.update(boards)
.set({
thumbnailUrl: urlData.publicUrl,
thumbnailUrl,
updatedAt: new Date(),
})
.where(eq(boards.id, id))

View file

@ -28,3 +28,15 @@ export interface UserStatsResponse {
archivedImages: number;
publicImages: number;
}
export interface RateLimitsResponse {
daily_used: number;
daily_limit: number;
daily_reset_at: string;
hourly_used: number;
hourly_limit: number;
hourly_reset_at: string;
active_generations: number;
max_concurrent: number;
total_all_time: number;
}

View file

@ -2,7 +2,7 @@ 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 { UpdateProfileDto, ProfileResponse, UserStatsResponse } from './dto/profile.dto';
import { UpdateProfileDto, ProfileResponse, UserStatsResponse, RateLimitsResponse } from './dto/profile.dto';
@Controller('profiles')
@UseGuards(JwtAuthGuard)
@ -27,4 +27,9 @@ export class ProfileController {
async getMyStats(@CurrentUser() user: CurrentUserData): Promise<UserStatsResponse> {
return this.profileService.getUserStats(user.userId);
}
@Get('rate-limits')
async getRateLimits(@CurrentUser() user: CurrentUserData): Promise<RateLimitsResponse> {
return this.profileService.getRateLimits(user.userId);
}
}

View file

@ -1,9 +1,9 @@
import { Injectable, Inject, NotFoundException, Logger } from '@nestjs/common';
import { eq, and, isNull, isNotNull, sql } from 'drizzle-orm';
import { eq, and, isNull, isNotNull, sql, gte, inArray } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { profiles, images, type Profile } from '../db/schema';
import { UpdateProfileDto, ProfileResponse, UserStatsResponse } from './dto/profile.dto';
import { profiles, images, imageGenerations, type Profile } from '../db/schema';
import { UpdateProfileDto, ProfileResponse, UserStatsResponse, RateLimitsResponse } from './dto/profile.dto';
@Injectable()
export class ProfileService {
@ -132,4 +132,74 @@ export class ProfileService {
throw error;
}
}
async getRateLimits(userId: string): Promise<RateLimitsResponse> {
try {
const now = new Date();
// Calculate start of current day (UTC)
const startOfDay = new Date(now);
startOfDay.setUTCHours(0, 0, 0, 0);
// Calculate start of current hour
const startOfHour = new Date(now);
startOfHour.setUTCMinutes(0, 0, 0);
// Calculate reset times
const dailyReset = new Date(startOfDay);
dailyReset.setUTCDate(dailyReset.getUTCDate() + 1);
const hourlyReset = new Date(startOfHour);
hourlyReset.setUTCHours(hourlyReset.getUTCHours() + 1);
// Count daily generations
const dailyResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(imageGenerations)
.where(and(eq(imageGenerations.userId, userId), gte(imageGenerations.createdAt, startOfDay)));
// Count hourly generations
const hourlyResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(imageGenerations)
.where(and(eq(imageGenerations.userId, userId), gte(imageGenerations.createdAt, startOfHour)));
// Count active generations (pending, queued, processing)
const activeResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(imageGenerations)
.where(
and(
eq(imageGenerations.userId, userId),
inArray(imageGenerations.status, ['pending', 'queued', 'processing'])
)
);
// Count total all-time generations
const totalResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(imageGenerations)
.where(eq(imageGenerations.userId, userId));
// Default limits (can be made configurable later)
const DAILY_LIMIT = 100;
const HOURLY_LIMIT = 20;
const MAX_CONCURRENT = 5;
return {
daily_used: Number(dailyResult[0]?.count || 0),
daily_limit: DAILY_LIMIT,
daily_reset_at: dailyReset.toISOString(),
hourly_used: Number(hourlyResult[0]?.count || 0),
hourly_limit: HOURLY_LIMIT,
hourly_reset_at: hourlyReset.toISOString(),
active_generations: Number(activeResult[0]?.count || 0),
max_concurrent: MAX_CONCURRENT,
total_all_time: Number(totalResult[0]?.count || 0),
};
} catch (error) {
this.logger.error(`Error fetching rate limits for user ${userId}`, error);
throw error;
}
}
}

View file

@ -1,21 +1,79 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import * as fs from 'fs/promises';
import * as path from 'path';
export type StorageMode = 'local' | 's3';
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private supabase: SupabaseClient | null = null;
private readonly bucket = 'user-uploads';
private mode: StorageMode;
private s3Client: S3Client | null = null;
private readonly bucket: string;
private readonly localStoragePath: string;
private readonly publicUrlBase: string;
constructor(private configService: ConfigService) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL');
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
// Determine storage mode from environment
const storageMode = this.configService.get<string>('STORAGE_MODE', 'local');
this.mode = storageMode === 's3' ? 's3' : 'local';
if (supabaseUrl && supabaseKey) {
this.supabase = createClient(supabaseUrl, supabaseKey);
} else {
this.logger.warn('Supabase credentials not configured');
// S3 configuration (Hetzner Object Storage is S3-compatible)
const s3Endpoint = this.configService.get<string>('S3_ENDPOINT');
const s3Region = this.configService.get<string>('S3_REGION', 'eu-central-1');
const s3AccessKey = this.configService.get<string>('S3_ACCESS_KEY');
const s3SecretKey = this.configService.get<string>('S3_SECRET_KEY');
this.bucket = this.configService.get<string>('S3_BUCKET', 'picture-uploads');
// Local storage configuration
this.localStoragePath = this.configService.get<string>(
'LOCAL_STORAGE_PATH',
path.join(process.cwd(), 'uploads')
);
// Public URL base for serving files
const backendUrl = this.configService.get<string>('BACKEND_URL', 'http://localhost:3003');
this.publicUrlBase = this.configService.get<string>(
'STORAGE_PUBLIC_URL',
this.mode === 'local' ? `${backendUrl}/uploads` : `https://${this.bucket}.${s3Endpoint}`
);
if (this.mode === 's3') {
if (s3Endpoint && s3AccessKey && s3SecretKey) {
this.s3Client = new S3Client({
endpoint: s3Endpoint.startsWith('http') ? s3Endpoint : `https://${s3Endpoint}`,
region: s3Region,
credentials: {
accessKeyId: s3AccessKey,
secretAccessKey: s3SecretKey,
},
forcePathStyle: false, // Hetzner uses virtual-hosted style
});
this.logger.log(`Storage initialized in S3 mode (endpoint: ${s3Endpoint})`);
} else {
this.logger.warn('S3 credentials not configured, falling back to local storage');
this.mode = 'local';
}
}
if (this.mode === 'local') {
this.logger.log(`Storage initialized in local mode (path: ${this.localStoragePath})`);
this.ensureLocalStorageDirectory();
}
}
private async ensureLocalStorageDirectory(): Promise<void> {
try {
await fs.mkdir(this.localStoragePath, { recursive: true });
} catch (error) {
this.logger.error('Failed to create local storage directory', error);
}
}
@ -25,31 +83,64 @@ export class StorageService {
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 { error } = await this.supabase.storage.from(this.bucket).upload(storagePath, buffer, {
contentType,
upsert: false,
});
if (this.mode === 's3' && this.s3Client) {
return this.uploadToS3(buffer, storagePath, contentType);
} else {
return this.uploadToLocal(buffer, storagePath);
}
}
if (error) {
this.logger.error('Error uploading file to storage', error);
throw error;
private async uploadToS3(
buffer: Buffer,
storagePath: string,
contentType: string
): Promise<{ storagePath: string; publicUrl: string }> {
if (!this.s3Client) {
throw new Error('S3 client not configured');
}
const { data: urlData } = this.supabase.storage.from(this.bucket).getPublicUrl(storagePath);
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: storagePath,
Body: buffer,
ContentType: contentType,
ACL: 'public-read',
});
return {
storagePath,
publicUrl: urlData.publicUrl,
};
try {
await this.s3Client.send(command);
const publicUrl = `${this.publicUrlBase}/${storagePath}`;
return { storagePath, publicUrl };
} catch (error) {
this.logger.error('Error uploading file to S3', error);
throw error;
}
}
private async uploadToLocal(
buffer: Buffer,
storagePath: string
): Promise<{ storagePath: string; publicUrl: string }> {
const fullPath = path.join(this.localStoragePath, storagePath);
const directory = path.dirname(fullPath);
try {
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(fullPath, buffer);
const publicUrl = `${this.publicUrlBase}/${storagePath}`;
return { storagePath, publicUrl };
} catch (error) {
this.logger.error('Error uploading file to local storage', error);
throw error;
}
}
async uploadFromUrl(
@ -57,10 +148,6 @@ export class StorageService {
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) {
@ -74,40 +161,109 @@ export class StorageService {
}
async deleteFile(storagePath: string): Promise<void> {
if (!this.supabase) {
throw new Error('Supabase not configured');
if (this.mode === 's3' && this.s3Client) {
return this.deleteFromS3(storagePath);
} else {
return this.deleteFromLocal(storagePath);
}
}
private async deleteFromS3(storagePath: string): Promise<void> {
if (!this.s3Client) {
throw new Error('S3 client not configured');
}
const { error } = await this.supabase.storage.from(this.bucket).remove([storagePath]);
const command = new DeleteObjectCommand({
Bucket: this.bucket,
Key: storagePath,
});
if (error) {
this.logger.error(`Error deleting file ${storagePath}`, error);
try {
await this.s3Client.send(command);
} catch (error) {
this.logger.error(`Error deleting file ${storagePath} from S3`, error);
throw error;
}
}
private async deleteFromLocal(storagePath: string): Promise<void> {
const fullPath = path.join(this.localStoragePath, storagePath);
try {
await fs.unlink(fullPath);
} catch (error) {
// Ignore if file doesn't exist
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
this.logger.error(`Error deleting file ${storagePath} from local storage`, error);
throw error;
}
}
}
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 storagePath = `boards/${boardId}/thumbnail-${Date.now()}.png`;
const filename = `boards/${boardId}/thumbnail-${Date.now()}.png`;
if (this.mode === 's3' && this.s3Client) {
const result = await this.uploadToS3(buffer, storagePath, 'image/png');
return result.publicUrl;
} else {
const result = await this.uploadToLocal(buffer, storagePath);
return result.publicUrl;
}
}
const { error } = await this.supabase.storage.from(this.bucket).upload(filename, buffer, {
contentType: 'image/png',
upsert: true,
});
async getFile(storagePath: string): Promise<Buffer | null> {
if (this.mode === 's3' && this.s3Client) {
return this.getFromS3(storagePath);
} else {
return this.getFromLocal(storagePath);
}
}
if (error) {
this.logger.error('Error uploading board thumbnail', error);
throw error;
private async getFromS3(storagePath: string): Promise<Buffer | null> {
if (!this.s3Client) {
throw new Error('S3 client not configured');
}
const { data: urlData } = this.supabase.storage.from(this.bucket).getPublicUrl(filename);
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: storagePath,
});
return urlData.publicUrl;
try {
const response = await this.s3Client.send(command);
if (response.Body) {
const byteArray = await response.Body.transformToByteArray();
return Buffer.from(byteArray);
}
return null;
} catch (error) {
this.logger.error(`Error getting file ${storagePath} from S3`, error);
return null;
}
}
private async getFromLocal(storagePath: string): Promise<Buffer | null> {
const fullPath = path.join(this.localStoragePath, storagePath);
try {
return await fs.readFile(fullPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
this.logger.error(`Error getting file ${storagePath} from local storage`, error);
return null;
}
}
getStorageMode(): StorageMode {
return this.mode;
}
getPublicUrl(storagePath: string): string {
return `${this.publicUrlBase}/${storagePath}`;
}
}

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is the **mobile app** within the "picture" monorepo. It's an Expo React Native application built with TypeScript, using Expo Router for navigation and NativeWind (Tailwind CSS) for styling. The app integrates with Supabase for backend services and uses Zustand for state management.
This is the **mobile app** within the "picture" monorepo. It's an Expo React Native application built with TypeScript, using Expo Router for navigation and NativeWind (Tailwind CSS) for styling. The app integrates with a NestJS backend for all API calls and uses Zustand for state management.
## Monorepo Structure
@ -13,11 +13,12 @@ This app is part of a PNPM workspace monorepo:
```
picture/
├── apps/
│ ├── backend/ # NestJS API server
│ ├── mobile/ # This React Native app (Expo)
│ ├── web/ # SvelteKit web app
│ └── landing/ # Astro landing page
├── packages/
│ └── shared/ # Shared code (Supabase types, API client)
│ └── shared/ # Shared code (TypeScript types, utilities)
└── pnpm-workspace.yaml
```
@ -25,14 +26,12 @@ picture/
The shared package provides:
- **Supabase Database Types** - Auto-generated TypeScript types from database schema
- **Supabase Client** - Configured API client for all apps
- **Database Types** - TypeScript types from database schema
- **Shared Utilities** - Common helper functions and types
Import from shared package:
```tsx
import { supabase } from '@picture/shared';
import type { Database } from '@picture/shared/types';
```
@ -82,16 +81,28 @@ Zustand store in `store/store.ts` - Currently contains a sample "bears" store th
### Backend Integration
Supabase client is imported from `@picture/shared`:
All API calls go through the NestJS backend:
```tsx
import { supabase } from '@picture/shared';
import { fetchApi } from '~/services/api/client';
import { getRateLimits } from '~/services/api/profiles';
import { generateImage } from '~/services/api/generate';
```
- Shared client configured with AsyncStorage for auth persistence
- API client configured with JWT authentication via `@manacore/shared-auth`
- Environment variables managed at root level
- MCP server configured for Supabase integration (see root `.mcp.json`)
- Database types auto-generated in shared package
- Database is PostgreSQL accessed through the backend
### API Services
Located in `services/api/`:
- `client.ts` - Base API client with auth handling
- `images.ts` - Image CRUD operations
- `generate.ts` - Image generation endpoints
- `models.ts` - AI model endpoints
- `profiles.ts` - User profile and rate limits
- `tags.ts` - Image tagging
### Styling
@ -118,7 +129,7 @@ import { supabase } from '@picture/shared';
- **Navigation**: expo-router, react-navigation
- **UI**: NativeWind, @expo/vector-icons
- **Backend**: @supabase/supabase-js
- **Auth**: @manacore/shared-auth
- **State**: zustand
- **Development**: expo-dev-client for custom native builds
@ -126,8 +137,8 @@ import { supabase } from '@picture/shared';
Required environment variables (in `.env` or similar):
- `EXPO_PUBLIC_SUPABASE_URL` - Supabase project URL
- `EXPO_PUBLIC_SUPABASE_ANON_KEY` - Supabase anonymous key
- `EXPO_PUBLIC_API_URL` - Backend API URL
- `EXPO_PUBLIC_MIDDLEWARE_API_URL` - Auth middleware URL
## EAS Build Configuration
@ -137,35 +148,3 @@ The project is configured for EAS Build with:
- Preview builds for internal distribution
- Production builds with auto-incrementing version numbers
- Project ID: `a74891be-7ff7-420c-9ff0-d33c37a59e5a`
## Supabase Edge Functions
### WICHTIG: Workflow für Edge Function Änderungen
**⚠️ KRITISCH: Bevor du eine Edge Function änderst, MUSS folgender Workflow eingehalten werden:**
1. **ERST Commit erstellen**
```bash
git add .
git commit -m "Before Edge Function changes"
```
2. **DANN lokale Änderungen vornehmen**
- Bearbeite die Function in `supabase/functions/[function-name]/`
- Teste lokal mit: `npx supabase functions serve [function-name]`
3. **ZULETZT auf Supabase deployen**
```bash
npx supabase functions deploy [function-name]
```
### Edge Functions Struktur
```
supabase/
└── functions/
└── [function-name]/
├── index.ts # Function Code
└── README.md # Dokumentation
```

View file

@ -2,22 +2,10 @@ import React, { useEffect, useState } from 'react';
import { View, Pressable } from 'react-native';
import { Icon } from './Icon';
import { Text } from './Text';
import { supabase } from '~/utils/supabase';
import { getRateLimits, type RateLimits } from '~/services/api/profiles';
import { useAuth } from '~/contexts/AuthContext';
import { useTheme } from '~/contexts/ThemeContext';
interface RateLimits {
daily_used: number;
daily_limit: number;
daily_reset_at: string;
hourly_used: number;
hourly_limit: number;
hourly_reset_at: string;
active_generations: number;
max_concurrent: number;
total_all_time: number;
}
interface RateLimitIndicatorProps {
compact?: boolean;
onRefresh?: () => void;
@ -34,11 +22,7 @@ export function RateLimitIndicator({ compact = false, onRefresh }: RateLimitIndi
if (!user) return;
try {
const { data, error } = await supabase.rpc('get_user_limits', {
p_user_id: user.id,
});
if (error) throw error;
const data = await getRateLimits();
setLimits(data);
} catch (error) {
console.error('Error fetching rate limits:', error);

View file

@ -24,7 +24,6 @@
"@picture/shared": "workspace:*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.0.3",
"@supabase/supabase-js": "^2.38.4",
"blurhash": "^2.0.5",
"expo": "54.0.12",
"expo-blur": "~15.0.7",

View file

@ -63,3 +63,36 @@ export async function getUserStats(): Promise<UserStats> {
}
);
}
export interface RateLimits {
daily_used: number;
daily_limit: number;
daily_reset_at: string;
hourly_used: number;
hourly_limit: number;
hourly_reset_at: string;
active_generations: number;
max_concurrent: number;
total_all_time: number;
}
/**
* Get user rate limits for image generation
*/
export async function getRateLimits(): Promise<RateLimits> {
const { data, error } = await fetchApi<RateLimits>('/profiles/rate-limits');
if (error) throw error;
return (
data || {
daily_used: 0,
daily_limit: 100,
daily_reset_at: new Date().toISOString(),
hourly_used: 0,
hourly_limit: 20,
hourly_reset_at: new Date().toISOString(),
active_generations: 0,
max_concurrent: 5,
total_all_time: 0,
}
);
}

View file

@ -1,59 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
import { Platform } from 'react-native';
import type { Database } from '@picture/shared/types';
const supabaseUrl =
process.env.EXPO_PUBLIC_SUPABASE_URL || 'https://mjuvnnjxwfwlmxjsgkqu.supabase.co';
const supabaseAnonKey =
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1qdXZubmp4d2Z3bG14anNna3F1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYyNTg5NTUsImV4cCI6MjA3MTgzNDk1NX0.EeOKzyPnZ42zpFl7oi54qDcAZSW-XGoB0tSNwUiX9GU';
// Create a storage adapter that works for both web and mobile
const createStorage = () => {
if (Platform.OS === 'web') {
// For web, use a simple localStorage wrapper
return {
getItem: async (key: string) => {
try {
if (typeof window !== 'undefined' && window.localStorage) {
const item = window.localStorage.getItem(key);
return item;
}
} catch (error) {
console.error('Error getting item from localStorage:', error);
}
return null;
},
setItem: async (key: string, value: string) => {
try {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem(key, value);
}
} catch (error) {
console.error('Error setting item in localStorage:', error);
}
},
removeItem: async (key: string) => {
try {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem(key);
}
} catch (error) {
console.error('Error removing item from localStorage:', error);
}
},
};
}
// For mobile, use AsyncStorage
return AsyncStorage;
};
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
auth: {
storage: createStorage(),
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: Platform.OS === 'web',
},
});

View file

@ -1,5 +1,12 @@
PUBLIC_SUPABASE_URL=https://mjuvnnjxwfwlmxjsgkqu.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
# Backend API
PUBLIC_BACKEND_URL=http://localhost:3003
# Mana Core Auth
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# OAuth (optional - leave empty to disable)
PUBLIC_GOOGLE_CLIENT_ID=
PUBLIC_APPLE_CLIENT_ID=
# Umami Analytics
PUBLIC_UMAMI_URL=https://your-umami-instance.com

View file

@ -1,136 +0,0 @@
# Storage Bucket Setup für User Uploads
## Schritt 1: SQL Statement ausführen
### Option A: Erste Installation (Policies existieren noch nicht)
1. Öffne die **Supabase Dashboard**: https://supabase.com/dashboard
2. Wähle dein Projekt aus
3. Navigiere zu **SQL Editor**
4. Kopiere den Inhalt von `setup-storage-bucket.sql`
5. Führe das SQL-Script aus
### Option B: Update (Policies existieren bereits)
1. Öffne die **Supabase Dashboard**: https://supabase.com/dashboard
2. Wähle dein Projekt aus
3. Navigiere zu **SQL Editor**
4. Kopiere den Inhalt von `update-storage-policies.sql`
5. Führe das SQL-Script aus
**Falls Fehler "policy already exists"**: Verwende `update-storage-policies.sql` statt `setup-storage-bucket.sql`
## Schritt 2: Überprüfung
### Bucket überprüfen
Navigiere zu **Storage** im Supabase Dashboard:
- Du solltest einen Bucket namens `user-uploads` sehen
- Public: ✓ Enabled
- File Size Limit: 10 MB
- Allowed MIME types: image/jpeg, image/jpg, image/png, image/webp
### Policies überprüfen
Navigiere zu **Storage > Policies**:
- Du solltest 4 Policies für `user-uploads` sehen:
- ✓ Users can upload their own images (INSERT)
- ✓ Public images are publicly accessible (SELECT)
- ✓ Users can update their own images (UPDATE)
- ✓ Users can delete their own images (DELETE)
## Schritt 3: Testen
### Test 1: Upload über die Web-App
1. Öffne die Web-App: http://localhost:5173/app/upload
2. Wähle ein Bild aus oder nutze Drag & Drop
3. Klicke auf "Upload"
4. Das Bild sollte erfolgreich hochgeladen werden
5. Überprüfe in **Storage > user-uploads** im Supabase Dashboard
### Test 2: Zugriff auf öffentliche URL
1. Nachdem Upload erfolgreich war, kopiere die `public_url` aus der Konsole
2. Öffne die URL in einem neuen Browser-Tab
3. Das Bild sollte sichtbar sein (ohne Authentifizierung)
### Test 3: Galerie-Integration
1. Navigiere zur Galerie: http://localhost:5173/app/gallery
2. Die hochgeladenen Bilder sollten in der Galerie erscheinen
3. Klicke auf ein Bild, um die Detail-Ansicht zu öffnen
## Datei-Struktur im Bucket
```
user-uploads/
├── {user_id_1}/
│ ├── 1234567890-abc123.jpg
│ ├── 1234567891-def456.png
│ └── 1234567892-ghi789.webp
└── {user_id_2}/
├── 1234567893-jkl012.jpg
└── 1234567894-mno345.png
```
## Sicherheit
### ✅ Was ist geschützt:
- User können nur in ihren eigenen Ordner hochladen
- User können nur ihre eigenen Dateien bearbeiten/löschen
- Upload nur für authentifizierte User
- Datei-Größe ist auf 10MB begrenzt
- Nur erlaubte Bild-Formate (JPG, PNG, WebP)
### ⚠️ Was ist öffentlich:
- Alle hochgeladenen Bilder sind über ihre public_url zugänglich
- Jeder mit der URL kann das Bild sehen (auch ohne Account)
- Dies ist gewollt für die Galerie-Anzeige
### 🔒 Optionale Verbesserungen für später:
- Private Bilder: Separate Bucket für private Uploads
- Signed URLs: Temporäre URLs für sensible Inhalte
- CDN: CloudFlare oder AWS CloudFront vor Supabase Storage
## Troubleshooting
### Fehler: "Bucket bereits vorhanden"
- Kein Problem! Das Script verwendet `ON CONFLICT DO NOTHING`
- Die Policies werden trotzdem erstellt
### Fehler: "Permission denied"
1. Überprüfe ob du als authentifizierter User eingeloggt bist
2. Überprüfe die Policies im Supabase Dashboard
3. Führe das SQL-Script erneut aus
### Fehler: "File too large"
- Stelle sicher, dass die Datei kleiner als 10MB ist
- Die Validierung erfolgt sowohl im Frontend als auch im Backend
### Bilder werden nicht in der Galerie angezeigt
1. Überprüfe ob der Bucket `public` ist
2. Überprüfe ob die `public_url` korrekt generiert wird
3. Öffne die Browser-Konsole für Fehler-Logs
## Alternative: UI-basiertes Setup
Falls du das SQL-Script nicht ausführen möchtest, kannst du den Bucket auch manuell im UI erstellen:
1. **Storage > Create Bucket**
- Name: `user-uploads`
- Public: ✓ Enable
- File Size Limit: 10485760 (10MB)
- Allowed MIME types: image/jpeg, image/jpg, image/png, image/webp
2. **Storage > Policies > New Policy**
- Erstelle die 4 Policies manuell mit den gleichen Bedingungen wie im SQL-Script

View file

@ -1,101 +0,0 @@
-- ============================================
-- Storage Bucket Setup für User Uploads
-- ============================================
-- Dieses Script muss in der Supabase SQL-Konsole ausgeführt werden
-- 1. Erstelle Storage Bucket für User Uploads
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'user-uploads',
'user-uploads',
true, -- Public bucket, damit Bilder über public_url zugänglich sind
10485760, -- 10MB in Bytes (10 * 1024 * 1024)
ARRAY['image/jpeg', 'image/jpg', 'image/png', 'image/webp']::text[]
)
ON CONFLICT (id) DO NOTHING;
-- 2. Storage Policy: Benutzer können nur ihre eigenen Dateien hochladen
CREATE POLICY "Users can upload their own images"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'user-uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- 3. Storage Policy: Jeder kann Bilder lesen (public bucket)
CREATE POLICY "Public images are publicly accessible"
ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'user-uploads');
-- 4. Storage Policy: Benutzer können nur ihre eigenen Dateien aktualisieren
CREATE POLICY "Users can update their own images"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'user-uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
)
WITH CHECK (
bucket_id = 'user-uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- 5. Storage Policy: Benutzer können nur ihre eigenen Dateien löschen
CREATE POLICY "Users can delete their own images"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'user-uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- ============================================
-- Überprüfung der Bucket-Konfiguration
-- ============================================
-- Führe diese Queries aus, um die Konfiguration zu überprüfen:
-- Bucket-Details anzeigen
SELECT * FROM storage.buckets WHERE id = 'user-uploads';
-- Alle Policies für den Bucket anzeigen
SELECT
schemaname,
tablename,
policyname,
permissive,
roles,
cmd,
qual,
with_check
FROM pg_policies
WHERE tablename = 'objects'
AND policyname ILIKE '%user%'
ORDER BY policyname;
-- ============================================
-- Hinweise
-- ============================================
--
-- 1. Die Datei-Struktur im Bucket ist: user-uploads/{user_id}/{timestamp}-{random}.{ext}
-- Dies stellt sicher, dass jeder User nur auf seine eigenen Dateien zugreifen kann.
--
-- 2. Der Bucket ist PUBLIC, d.h. Bilder sind über die public_url ohne Auth zugänglich.
-- Dies ist notwendig, damit Bilder in der Galerie angezeigt werden können.
--
-- 3. Die Policies stellen sicher, dass:
-- - Nur authentifizierte User hochladen können
-- - User nur in ihren eigenen Ordner ({user_id}/) hochladen können
-- - Jeder User nur seine eigenen Dateien bearbeiten/löschen kann
-- - Alle Bilder öffentlich lesbar sind
--
-- 4. File Size Limit: 10MB pro Datei
-- Allowed Types: JPG, JPEG, PNG, WebP
--
-- 5. Falls der Bucket bereits existiert, wird er nicht neu erstellt (ON CONFLICT DO NOTHING)
--

View file

@ -64,7 +64,7 @@
}
});
// Navigation items
// Navigation items (Mana is in user dropdown via manaHref)
const navItems: PillNavItem[] = [
{ href: '/app/gallery', label: 'Galerie', icon: 'home' },
{ href: '/app/board', label: 'Moodboards', icon: 'grid' },
@ -73,7 +73,6 @@
{ href: '/app/upload', label: 'Upload', icon: 'upload' },
{ href: '/app/tags', label: 'Tags', icon: 'tag' },
{ href: '/app/archive', label: 'Archiv', icon: 'archive' },
{ href: '/app/mana', label: 'Mana', icon: 'mana' },
];
// View mode options for tab group

View file

@ -6,16 +6,13 @@
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./api": "./src/api/index.ts",
"./utils": "./src/utils/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"clean": "rm -rf dist .turbo node_modules"
},
"dependencies": {
"@supabase/supabase-js": "^2.38.4"
},
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.28.4",
"@babel/preset-typescript": "^7.27.1",

View file

@ -1 +1 @@
export * from './supabase';
// API exports - now handled via backend endpoints

View file

@ -1,8 +0,0 @@
import { createClient } from '@supabase/supabase-js';
import type { Database } from '../types/database.types';
export function createSupabaseClient(supabaseUrl: string, supabaseAnonKey: string) {
return createClient<Database>(supabaseUrl, supabaseAnonKey);
}
export type SupabaseClient = ReturnType<typeof createSupabaseClient>;

View file

@ -1,4 +1,2 @@
export * from './types';
export * from './api';
export * from './utils';
export * from './queue';

View file

@ -1,504 +0,0 @@
/**
* Job Queue Helper Functions
*
* Provides client-side utilities for interacting with the async job queue system.
* Uses Supabase database functions to enqueue jobs and monitor status.
*/
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from './types';
// ============================================================================
// TYPES
// ============================================================================
export type JobType = 'generate-image' | 'download-image' | 'process-webhook' | 'cleanup-storage';
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
export interface JobQueueRow {
id: string;
job_type: JobType;
payload: Record<string, any>;
status: JobStatus;
attempts: number;
max_attempts: number;
scheduled_at: string;
started_at: string | null;
completed_at: string | null;
error_message: string | null;
error_details: Record<string, any> | null;
created_by: string | null;
priority: number;
created_at: string;
updated_at: string;
}
export interface EnqueueJobParams {
jobType: JobType;
payload: Record<string, any>;
priority?: number;
scheduledAt?: Date;
maxAttempts?: number;
}
export interface JobStats {
total: number;
pending: number;
processing: number;
completed: number;
failed: number;
avgDurationSeconds: number;
}
// ============================================================================
// QUEUE FUNCTIONS
// ============================================================================
/**
* Enqueue a new job for background processing
*
* @example
* ```typescript
* const jobId = await enqueueJob(supabase, {
* jobType: 'generate-image',
* payload: { prompt: 'A beautiful sunset', model_id: 'flux-dev' },
* priority: 10
* });
* ```
*/
export async function enqueueJob(
supabase: SupabaseClient<Database>,
params: EnqueueJobParams
): Promise<string> {
const { jobType, payload, priority = 0, scheduledAt = new Date(), maxAttempts = 3 } = params;
const { data, error } = await supabase.rpc('enqueue_job', {
p_job_type: jobType,
p_payload: payload as any,
p_priority: priority,
p_scheduled_at: scheduledAt.toISOString(),
p_max_attempts: maxAttempts,
});
if (error) {
console.error('Failed to enqueue job:', error);
throw new Error(`Failed to enqueue job: ${error.message}`);
}
return data as string;
}
/**
* Get job by ID
*/
export async function getJob(
supabase: SupabaseClient<Database>,
jobId: string
): Promise<JobQueueRow | null> {
const { data, error } = await supabase.from('job_queue').select('*').eq('id', jobId).single();
if (error) {
if (error.code === 'PGRST116') {
return null; // Not found
}
throw error;
}
return data as JobQueueRow;
}
/**
* Get all jobs for current user
*/
export async function getUserJobs(
supabase: SupabaseClient<Database>,
options?: {
status?: JobStatus;
limit?: number;
offset?: number;
}
): Promise<JobQueueRow[]> {
let query = supabase.from('job_queue').select('*').order('created_at', { ascending: false });
if (options?.status) {
query = query.eq('status', options.status);
}
if (options?.limit) {
query = query.limit(options.limit);
}
if (options?.offset) {
query = query.range(options.offset, options.offset + (options.limit || 10) - 1);
}
const { data, error } = await query;
if (error) {
throw error;
}
return (data || []) as JobQueueRow[];
}
/**
* Cancel a pending job
*/
export async function cancelJob(supabase: SupabaseClient<Database>, jobId: string): Promise<void> {
const { error } = await supabase
.from('job_queue')
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
.eq('id', jobId)
.eq('status', 'pending'); // Only cancel pending jobs
if (error) {
throw new Error(`Failed to cancel job: ${error.message}`);
}
}
/**
* Get queue health statistics
*/
export async function getQueueStats(
supabase: SupabaseClient<Database>,
jobType?: JobType
): Promise<JobStats> {
const { data, error } = await supabase.from('queue_health').select('*');
if (error) {
throw error;
}
// Aggregate stats
let stats: JobStats = {
total: 0,
pending: 0,
processing: 0,
completed: 0,
failed: 0,
avgDurationSeconds: 0,
};
const filtered = jobType ? data?.filter((row) => row.job_type === jobType) : data;
filtered?.forEach((row) => {
const count = row.count || 0;
stats.total += count;
switch (row.status) {
case 'pending':
stats.pending += count;
break;
case 'processing':
stats.processing += count;
break;
case 'completed':
stats.completed += count;
break;
case 'failed':
stats.failed += count;
break;
}
if (row.avg_duration_seconds) {
stats.avgDurationSeconds = row.avg_duration_seconds;
}
});
return stats;
}
/**
* Get failed jobs (last 24 hours)
*/
export async function getRecentFailedJobs(
supabase: SupabaseClient<Database>
): Promise<JobQueueRow[]> {
const { data, error } = await supabase.from('failed_jobs_recent').select('*');
if (error) {
throw error;
}
return (data || []) as JobQueueRow[];
}
// ============================================================================
// REALTIME SUBSCRIPTION HELPERS
// ============================================================================
export type JobUpdateCallback = (job: JobQueueRow) => void;
/**
* Subscribe to job updates via Realtime
*
* @example
* ```typescript
* const unsubscribe = subscribeToJob(supabase, jobId, (job) => {
* console.log('Job updated:', job.status);
* if (job.status === 'completed') {
* unsubscribe();
* }
* });
* ```
*/
export function subscribeToJob(
supabase: SupabaseClient<Database>,
jobId: string,
callback: JobUpdateCallback
): () => void {
const channel = supabase
.channel(`job:${jobId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'job_queue',
filter: `id=eq.${jobId}`,
},
(payload) => {
callback(payload.new as JobQueueRow);
}
)
.subscribe();
return () => {
channel.unsubscribe();
};
}
/**
* Subscribe to all job updates for current user
*/
export function subscribeToUserJobs(
supabase: SupabaseClient<Database>,
userId: string,
callback: JobUpdateCallback
): () => void {
const channel = supabase
.channel(`user-jobs:${userId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'job_queue',
filter: `created_by=eq.${userId}`,
},
(payload) => {
if (payload.new) {
callback(payload.new as JobQueueRow);
}
}
)
.subscribe();
return () => {
channel.unsubscribe();
};
}
// ============================================================================
// IMAGE GENERATION HELPERS (Convenience wrappers)
// ============================================================================
export interface GenerateImageJobParams {
prompt: string;
model_id: string;
model_version?: string;
width?: number;
height?: number;
num_inference_steps?: number;
guidance_scale?: number;
seed?: number;
negative_prompt?: string;
source_image_url?: string;
strength?: number;
style?: string;
}
/**
* Start an image generation job (high-level wrapper)
*
* @example
* ```typescript
* const { generationId, jobId } = await startImageGeneration(supabase, {
* prompt: 'A beautiful sunset over mountains',
* model_id: 'black-forest-labs/flux-dev'
* });
*
* // Subscribe to updates
* subscribeToGeneration(supabase, generationId, (generation) => {
* console.log('Status:', generation.status);
* });
* ```
*/
export async function startImageGeneration(
supabase: SupabaseClient<Database>,
params: GenerateImageJobParams
): Promise<{ generationId: string; jobId: string }> {
// Call start-generation Edge Function
const { data, error } = await supabase.functions.invoke('start-generation', {
body: params,
});
if (error) {
throw new Error(`Failed to start generation: ${error.message}`);
}
if (!data.success) {
throw new Error(data.error || 'Failed to start generation');
}
return {
generationId: data.generation_id,
jobId: data.job_id,
};
}
/**
* Subscribe to generation updates via Realtime
*/
export function subscribeToGeneration(
supabase: SupabaseClient<Database>,
generationId: string,
callback: (generation: any) => void
): () => void {
const channel = supabase
.channel(`generation:${generationId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'image_generations',
filter: `id=eq.${generationId}`,
},
(payload) => {
callback(payload.new);
}
)
.subscribe();
return () => {
channel.unsubscribe();
};
}
/**
* Combined helper: Start generation and subscribe to updates
*
* @example
* ```typescript
* const { generationId, unsubscribe } = await generateImageWithUpdates(
* supabase,
* { prompt: 'A sunset', model_id: 'flux-dev' },
* (generation) => {
* console.log('Status:', generation.status);
* if (generation.status === 'completed') {
* console.log('Image ready!', generation.id);
* unsubscribe();
* }
* }
* );
* ```
*/
export async function generateImageWithUpdates(
supabase: SupabaseClient<Database>,
params: GenerateImageJobParams,
onUpdate: (generation: any) => void
): Promise<{ generationId: string; jobId: string; unsubscribe: () => void }> {
// Start generation
const { generationId, jobId } = await startImageGeneration(supabase, params);
// Subscribe to updates
const unsubscribe = subscribeToGeneration(supabase, generationId, onUpdate);
return { generationId, jobId, unsubscribe };
}
// ============================================================================
// POLLING HELPERS (fallback if Realtime not available)
// ============================================================================
/**
* Poll for job status (fallback for environments without Realtime)
*/
export async function pollJobUntilComplete(
supabase: SupabaseClient<Database>,
jobId: string,
options: {
maxAttempts?: number;
intervalMs?: number;
onUpdate?: JobUpdateCallback;
} = {}
): Promise<JobQueueRow> {
const { maxAttempts = 60, intervalMs = 2000, onUpdate } = options;
let attempts = 0;
while (attempts < maxAttempts) {
const job = await getJob(supabase, jobId);
if (!job) {
throw new Error('Job not found');
}
if (onUpdate) {
onUpdate(job);
}
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
return job;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
attempts++;
}
throw new Error('Job polling timeout');
}
/**
* Poll for generation completion
*/
export async function pollGenerationUntilComplete(
supabase: SupabaseClient<Database>,
generationId: string,
options: {
maxAttempts?: number;
intervalMs?: number;
onUpdate?: (generation: any) => void;
} = {}
): Promise<any> {
const { maxAttempts = 120, intervalMs = 2000, onUpdate } = options;
let attempts = 0;
while (attempts < maxAttempts) {
const { data: generation, error } = await supabase
.from('image_generations')
.select('*')
.eq('id', generationId)
.single();
if (error) {
throw error;
}
if (onUpdate) {
onUpdate(generation);
}
if (generation.status === 'completed' || generation.status === 'failed') {
return generation;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
attempts++;
}
throw new Error('Generation polling timeout');
}

1223
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -224,11 +224,21 @@ const APP_CONFIGS = [
path: 'apps/picture/apps/backend/.env',
vars: {
NODE_ENV: () => 'development',
PORT: () => '3003',
DATABASE_URL: () => 'postgresql://picture:picturepassword@localhost:5434/picture',
PORT: (env) => env.PICTURE_BACKEND_PORT || '3003',
BACKEND_URL: (env) => env.PICTURE_BACKEND_URL || 'http://localhost:3003',
DATABASE_URL: (env) => env.PICTURE_DATABASE_URL || 'postgresql://picture:picturepassword@localhost:5434/picture',
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
REPLICATE_API_TOKEN: (env) => env.MAERCHENZAUBER_REPLICATE_API_KEY, // Reuse existing Replicate key
REPLICATE_API_TOKEN: (env) => env.MAERCHENZAUBER_REPLICATE_API_KEY,
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
// Storage configuration
STORAGE_MODE: (env) => env.PICTURE_STORAGE_MODE || 'local',
LOCAL_STORAGE_PATH: (env) => env.PICTURE_LOCAL_STORAGE_PATH || './uploads',
S3_ENDPOINT: (env) => env.PICTURE_S3_ENDPOINT || '',
S3_REGION: (env) => env.PICTURE_S3_REGION || 'eu-central-1',
S3_ACCESS_KEY: (env) => env.PICTURE_S3_ACCESS_KEY || '',
S3_SECRET_KEY: (env) => env.PICTURE_S3_SECRET_KEY || '',
S3_BUCKET: (env) => env.PICTURE_S3_BUCKET || 'picture-uploads',
STORAGE_PUBLIC_URL: (env) => env.PICTURE_STORAGE_PUBLIC_URL || '',
},
},
@ -241,7 +251,7 @@ const APP_CONFIGS = [
},
},
// Picture Web (SvelteKit)
// Picture Web (SvelteKit) - No Supabase, uses Backend API
{
path: 'apps/picture/apps/web/.env',
vars: {