diff --git a/apps/picture/apps/backend/src/generate/__tests__/generate.service.spec.ts b/apps/picture/apps/backend/src/generate/__tests__/generate.service.spec.ts new file mode 100644 index 000000000..e0770d2f3 --- /dev/null +++ b/apps/picture/apps/backend/src/generate/__tests__/generate.service.spec.ts @@ -0,0 +1,1064 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, ForbiddenException, HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { GenerateService } from '../generate.service'; +import { ReplicateService } from '../replicate.service'; +import { StorageService } from '../../upload/storage.service'; +import { CreditClientService } from '@manacore/nestjs-integration'; +import { DATABASE_CONNECTION } from '../../db/database.module'; + +// ── Mock helpers ────────────────────────────────────────────────────── + +const NOW = new Date('2026-01-15T12:00:00Z'); + +const makeModel = (overrides: Record = {}) => ({ + id: 'model-1', + name: 'flux-schnell', + displayName: 'Flux Schnell', + description: 'Fast generation', + replicateId: 'black-forest-labs/flux-schnell', + version: 'abc123', + defaultWidth: 1024, + defaultHeight: 1024, + defaultSteps: 25, + defaultGuidanceScale: 7.5, + minWidth: 512, + minHeight: 512, + maxWidth: 2048, + maxHeight: 2048, + maxSteps: 50, + supportsNegativePrompt: true, + supportsImg2Img: false, + supportsSeed: true, + isActive: true, + isDefault: false, + sortOrder: 0, + costPerGeneration: null, + estimatedTimeSeconds: 10, + createdAt: NOW, + updatedAt: NOW, + ...overrides, +}); + +const makeGeneration = (overrides: Record = {}) => ({ + id: 'gen-1', + userId: 'user-1', + modelId: 'model-1', + batchId: null, + prompt: 'A beautiful sunset over the ocean', + negativePrompt: null, + model: 'flux-schnell', + style: null, + sourceImageUrl: null, + width: 1024, + height: 1024, + steps: 25, + guidanceScale: 7.5, + seed: null, + generationStrength: null, + status: 'pending', + replicatePredictionId: null, + errorMessage: null, + generationTimeSeconds: null, + retryCount: 0, + priority: 0, + createdAt: NOW, + completedAt: null, + ...overrides, +}); + +const makeImage = (overrides: Record = {}) => ({ + id: 'img-1', + userId: 'user-1', + generationId: 'gen-1', + sourceImageId: null, + prompt: 'A beautiful sunset over the ocean', + negativePrompt: null, + model: 'flux-schnell', + style: null, + publicUrl: 'https://cdn.example.com/images/generated-gen-1.webp', + storagePath: 'user-1/generated-gen-1.webp', + filename: 'generated-gen-1.webp', + format: 'webp', + width: 1024, + height: 1024, + fileSize: null, + blurhash: null, + isPublic: false, + isFavorite: false, + downloadCount: 0, + rating: null, + archivedAt: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, +}); + +const makeDto = (overrides: Record = {}) => ({ + prompt: 'A beautiful sunset over the ocean', + modelId: 'model-1', + ...overrides, +}); + +// Drizzle fluent chain mock +function createChainMock(terminal: jest.Mock) { + const chain: any = {}; + const methods = [ + 'from', + 'where', + 'orderBy', + 'limit', + 'offset', + 'groupBy', + 'having', + 'set', + 'values', + 'returning', + ]; + for (const m of methods) { + chain[m] = jest.fn().mockReturnValue(chain); + } + chain.then = (resolve: any, reject: any) => terminal().then(resolve, reject); + (chain as any)[Symbol.toStringTag] = 'Promise'; + return chain; +} + +let selectResult: jest.Mock; +let selectChain: any; +let insertResult: jest.Mock; +let insertChain: any; +let updateResult: jest.Mock; +let updateChain: any; +let deleteResult: jest.Mock; +let deleteChain: any; +let mockDb: any; + +function buildMockDb() { + selectResult = jest.fn().mockResolvedValue([]); + selectChain = createChainMock(selectResult); + + insertResult = jest.fn().mockResolvedValue([]); + insertChain = createChainMock(insertResult); + + updateResult = jest.fn().mockResolvedValue([]); + updateChain = createChainMock(updateResult); + + deleteResult = jest.fn().mockResolvedValue([]); + deleteChain = createChainMock(deleteResult); + + mockDb = { + select: jest.fn().mockReturnValue(selectChain), + insert: jest.fn().mockReturnValue(insertChain), + update: jest.fn().mockReturnValue(updateChain), + delete: jest.fn().mockReturnValue(deleteChain), + transaction: jest.fn(), + }; +} + +const mockReplicateService = { + processGeneration: jest.fn(), + createPrediction: jest.fn(), + getPrediction: jest.fn(), + cancelPrediction: jest.fn(), +}; + +const mockStorageService = { + uploadFromUrl: jest.fn(), +}; + +const mockCreditClient = { + getBalance: jest.fn(), + consumeCredits: jest.fn(), +}; + +// ── Test suite ──────────────────────────────────────────────────────── + +describe('GenerateService', () => { + let service: GenerateService; + let configValues: Record; + + function createService(overrides: Record = {}) { + configValues = { + WEBHOOK_BASE_URL: 'http://localhost:3003', + NODE_ENV: 'development', + ...overrides, + }; + } + + beforeEach(async () => { + buildMockDb(); + jest.clearAllMocks(); + createService(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GenerateService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: ReplicateService, useValue: mockReplicateService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: CreditClientService, useValue: mockCreditClient }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => configValues[key]), + }, + }, + ], + }).compile(); + + service = module.get(GenerateService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // ── checkGenerationAccess ────────────────────────────────────── + + describe('checkGenerationAccess', () => { + it('should skip credit check in development and allow generation', async () => { + const result = await service.checkGenerationAccess('user-1'); + + expect(result.canGenerate).toBe(true); + expect(result.creditsRequired).toBe(10); + expect(result.currentBalance).toBeUndefined(); + expect(mockCreditClient.getBalance).not.toHaveBeenCalled(); + }); + + it('should check credits in production and allow when sufficient', async () => { + // Rebuild service with production config + buildMockDb(); + createService({ NODE_ENV: 'production' }); + + const module = await Test.createTestingModule({ + providers: [ + GenerateService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: ReplicateService, useValue: mockReplicateService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: CreditClientService, useValue: mockCreditClient }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => configValues[key]), + }, + }, + ], + }).compile(); + + const prodService = module.get(GenerateService); + mockCreditClient.getBalance.mockResolvedValue({ balance: 100 }); + + const result = await prodService.checkGenerationAccess('user-1'); + + expect(result.canGenerate).toBe(true); + expect(result.creditsRequired).toBe(10); + expect(result.currentBalance).toBe(100); + expect(mockCreditClient.getBalance).toHaveBeenCalledWith('user-1'); + }); + + it('should deny generation in production when credits insufficient', async () => { + buildMockDb(); + createService({ NODE_ENV: 'production' }); + + const module = await Test.createTestingModule({ + providers: [ + GenerateService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: ReplicateService, useValue: mockReplicateService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: CreditClientService, useValue: mockCreditClient }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => configValues[key]), + }, + }, + ], + }).compile(); + + const prodService = module.get(GenerateService); + mockCreditClient.getBalance.mockResolvedValue({ balance: 5 }); + + const result = await prodService.checkGenerationAccess('user-1'); + + expect(result.canGenerate).toBe(false); + expect(result.currentBalance).toBe(5); + }); + + it('should fail open on credit check error in production', async () => { + buildMockDb(); + createService({ NODE_ENV: 'production' }); + + const module = await Test.createTestingModule({ + providers: [ + GenerateService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: ReplicateService, useValue: mockReplicateService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: CreditClientService, useValue: mockCreditClient }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => configValues[key]), + }, + }, + ], + }).compile(); + + const prodService = module.get(GenerateService); + mockCreditClient.getBalance.mockRejectedValue(new Error('Credit service down')); + + const result = await prodService.checkGenerationAccess('user-1'); + + expect(result.canGenerate).toBe(true); + expect(result.currentBalance).toBeUndefined(); + }); + }); + + // ── generateImage ────────────────────────────────────────────── + + describe('generateImage', () => { + it('should throw 402 on insufficient credits in production', async () => { + buildMockDb(); + createService({ NODE_ENV: 'production' }); + + const module = await Test.createTestingModule({ + providers: [ + GenerateService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: ReplicateService, useValue: mockReplicateService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: CreditClientService, useValue: mockCreditClient }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => configValues[key]), + }, + }, + ], + }).compile(); + + const prodService = module.get(GenerateService); + mockCreditClient.getBalance.mockResolvedValue({ balance: 3 }); + + await expect(prodService.generateImage('user-1', makeDto())).rejects.toThrow(HttpException); + + try { + await prodService.generateImage('user-1', makeDto()); + } catch (e) { + expect((e as HttpException).getStatus()).toBe(HttpStatus.PAYMENT_REQUIRED); + } + }); + + it('should throw NotFoundException for invalid model', async () => { + // Credit check passes (dev mode) + // Model lookup returns empty + selectResult.mockResolvedValue([]); + + await expect(service.generateImage('user-1', makeDto())).rejects.toThrow(NotFoundException); + }); + + it('should use sync mode when waitForResult is true', async () => { + const model = makeModel(); + const generation = makeGeneration(); + const image = makeImage(); + + // Model lookup + selectResult.mockResolvedValueOnce([model]); + + // Insert generation returning + insertChain.returning.mockResolvedValueOnce([generation]); + + // processGeneration succeeds + mockReplicateService.processGeneration.mockResolvedValue({ + success: true, + outputUrl: 'https://replicate.delivery/output.webp', + format: 'webp', + width: 1024, + height: 1024, + generationTimeSeconds: 5, + }); + + // uploadFromUrl + mockStorageService.uploadFromUrl.mockResolvedValue({ + storagePath: 'user-1/generated-gen-1.webp', + publicUrl: 'https://cdn.example.com/images/generated-gen-1.webp', + }); + + // Insert image returning + insertChain.returning.mockResolvedValueOnce([image]); + + const result = await service.generateImage('user-1', makeDto({ waitForResult: true })); + + expect(result.status).toBe('completed'); + expect(result.generationId).toBe('gen-1'); + expect(result.image).toEqual(image); + expect(mockReplicateService.processGeneration).toHaveBeenCalled(); + expect(mockReplicateService.createPrediction).not.toHaveBeenCalled(); + }); + + it('should use sync mode when webhooks are not available (no HTTPS)', async () => { + // Default config has http://localhost:3003, so sync mode is forced + const model = makeModel(); + const generation = makeGeneration(); + const image = makeImage(); + + selectResult.mockResolvedValueOnce([model]); + insertChain.returning.mockResolvedValueOnce([generation]); + + mockReplicateService.processGeneration.mockResolvedValue({ + success: true, + outputUrl: 'https://replicate.delivery/output.webp', + format: 'webp', + width: 1024, + height: 1024, + generationTimeSeconds: 5, + }); + + mockStorageService.uploadFromUrl.mockResolvedValue({ + storagePath: 'user-1/generated-gen-1.webp', + publicUrl: 'https://cdn.example.com/images/generated-gen-1.webp', + }); + + insertChain.returning.mockResolvedValueOnce([image]); + + // Not explicitly setting waitForResult - should still use sync because no HTTPS webhook + const result = await service.generateImage('user-1', makeDto()); + + expect(result.status).toBe('completed'); + expect(mockReplicateService.processGeneration).toHaveBeenCalled(); + expect(mockReplicateService.createPrediction).not.toHaveBeenCalled(); + }); + + it('should use async mode when HTTPS webhook is available', async () => { + buildMockDb(); + createService({ WEBHOOK_BASE_URL: 'https://api.example.com', NODE_ENV: 'development' }); + + const module = await Test.createTestingModule({ + providers: [ + GenerateService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: ReplicateService, useValue: mockReplicateService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: CreditClientService, useValue: mockCreditClient }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => configValues[key]), + }, + }, + ], + }).compile(); + + const asyncService = module.get(GenerateService); + + const model = makeModel(); + const generation = makeGeneration(); + + selectResult.mockResolvedValueOnce([model]); + insertChain.returning.mockResolvedValueOnce([generation]); + + mockReplicateService.createPrediction.mockResolvedValue({ + id: 'pred-1', + status: 'starting', + }); + + const result = await asyncService.generateImage('user-1', makeDto()); + + expect(result.status).toBe('processing'); + expect(result.generationId).toBe('gen-1'); + expect(mockReplicateService.createPrediction).toHaveBeenCalled(); + expect(mockReplicateService.processGeneration).not.toHaveBeenCalled(); + }); + + it('should consume credits after successful sync generation in production', async () => { + buildMockDb(); + createService({ + NODE_ENV: 'production', + WEBHOOK_BASE_URL: 'http://localhost:3003', + }); + + const module = await Test.createTestingModule({ + providers: [ + GenerateService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: ReplicateService, useValue: mockReplicateService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: CreditClientService, useValue: mockCreditClient }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => configValues[key]), + }, + }, + ], + }).compile(); + + const prodService = module.get(GenerateService); + + mockCreditClient.getBalance.mockResolvedValue({ balance: 100 }); + + const model = makeModel(); + const generation = makeGeneration(); + const image = makeImage(); + + selectResult.mockResolvedValueOnce([model]); + insertChain.returning.mockResolvedValueOnce([generation]); + + mockReplicateService.processGeneration.mockResolvedValue({ + success: true, + outputUrl: 'https://replicate.delivery/output.webp', + format: 'webp', + width: 1024, + height: 1024, + generationTimeSeconds: 5, + }); + + mockStorageService.uploadFromUrl.mockResolvedValue({ + storagePath: 'user-1/generated-gen-1.webp', + publicUrl: 'https://cdn.example.com/images/generated-gen-1.webp', + }); + + insertChain.returning.mockResolvedValueOnce([image]); + + const result = await prodService.generateImage('user-1', makeDto({ waitForResult: true })); + + expect(result.status).toBe('completed'); + expect(result.creditsUsed).toBe(10); + expect(mockCreditClient.consumeCredits).toHaveBeenCalledWith( + 'user-1', + 'image_generation', + 10, + expect.stringContaining('gen-1') + ); + }); + + it('should not consume credits in development after sync generation', async () => { + const model = makeModel(); + const generation = makeGeneration(); + const image = makeImage(); + + selectResult.mockResolvedValueOnce([model]); + insertChain.returning.mockResolvedValueOnce([generation]); + + mockReplicateService.processGeneration.mockResolvedValue({ + success: true, + outputUrl: 'https://replicate.delivery/output.webp', + format: 'webp', + width: 1024, + height: 1024, + generationTimeSeconds: 5, + }); + + mockStorageService.uploadFromUrl.mockResolvedValue({ + storagePath: 'user-1/generated-gen-1.webp', + publicUrl: 'https://cdn.example.com/images/generated-gen-1.webp', + }); + + insertChain.returning.mockResolvedValueOnce([image]); + + await service.generateImage('user-1', makeDto({ waitForResult: true })); + + expect(mockCreditClient.consumeCredits).not.toHaveBeenCalled(); + }); + + it('should handle sync generation failure gracefully', async () => { + const model = makeModel(); + const generation = makeGeneration(); + + selectResult.mockResolvedValueOnce([model]); + insertChain.returning.mockResolvedValueOnce([generation]); + + mockReplicateService.processGeneration.mockResolvedValue({ + success: false, + error: 'Model unavailable', + }); + + const result = await service.generateImage('user-1', makeDto({ waitForResult: true })); + + expect(result.status).toBe('failed'); + expect(result.generationId).toBe('gen-1'); + expect(result.image).toBeUndefined(); + }); + }); + + // ── checkStatus ──────────────────────────────────────────────── + + describe('checkStatus', () => { + it('should return completed generation with associated image', async () => { + const generation = makeGeneration({ status: 'completed', completedAt: NOW }); + const image = makeImage(); + + // First select: generation lookup + selectResult.mockResolvedValueOnce([generation]); + // Second select: image lookup + selectResult.mockResolvedValueOnce([image]); + + const result = await service.checkStatus('gen-1', 'user-1'); + + expect(result.status).toBe('completed'); + expect(result.image).toEqual(image); + }); + + it('should throw NotFoundException for non-existent generation', async () => { + selectResult.mockResolvedValue([]); + + await expect(service.checkStatus('non-existent', 'user-1')).rejects.toThrow( + NotFoundException + ); + }); + + it('should throw ForbiddenException when user does not own generation', async () => { + const generation = makeGeneration({ userId: 'user-other' }); + selectResult.mockResolvedValueOnce([generation]); + + await expect(service.checkStatus('gen-1', 'user-1')).rejects.toThrow(ForbiddenException); + }); + + it('should poll Replicate when generation is processing with prediction ID', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + // First select: generation lookup + selectResult.mockResolvedValueOnce([generation]); + + // Replicate returns succeeded + mockReplicateService.getPrediction.mockResolvedValue({ + id: 'pred-1', + status: 'succeeded', + output: ['https://replicate.delivery/output.webp'], + }); + + // uploadFromUrl for processCompletedGeneration + mockStorageService.uploadFromUrl.mockResolvedValue({ + storagePath: 'user-1/generated-gen-1.webp', + publicUrl: 'https://cdn.example.com/images/generated-gen-1.webp', + }); + + // Transaction mock for processCompletedGeneration + const txInsertChain: any = {}; + const txUpdateChain: any = {}; + for (const m of ['from', 'where', 'set', 'values', 'returning', 'limit']) { + txInsertChain[m] = jest.fn().mockReturnValue(txInsertChain); + txUpdateChain[m] = jest.fn().mockReturnValue(txUpdateChain); + } + txInsertChain.then = (r: any) => Promise.resolve().then(r); + txUpdateChain.then = (r: any) => Promise.resolve().then(r); + + mockDb.transaction.mockImplementation((cb: any) => + cb({ + insert: jest.fn().mockReturnValue(txInsertChain), + update: jest.fn().mockReturnValue(txUpdateChain), + }) + ); + + // Refetch after processing: updated generation + const updatedGeneration = makeGeneration({ status: 'completed', completedAt: NOW }); + selectResult.mockResolvedValueOnce([updatedGeneration]); + + // Image fetch + const image = makeImage(); + selectResult.mockResolvedValueOnce([image]); + + const result = await service.checkStatus('gen-1', 'user-1'); + + expect(mockReplicateService.getPrediction).toHaveBeenCalledWith('pred-1'); + expect(result.status).toBe('completed'); + expect(result.image).toEqual(image); + }); + + it('should update status to failed when Replicate prediction failed', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + + mockReplicateService.getPrediction.mockResolvedValue({ + id: 'pred-1', + status: 'failed', + error: 'GPU out of memory', + }); + + const result = await service.checkStatus('gen-1', 'user-1'); + + expect(result.status).toBe('failed'); + expect(result.errorMessage).toBe('GPU out of memory'); + expect(mockDb.update).toHaveBeenCalled(); + }); + + it('should return generation as-is when still processing (no completion yet)', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + + mockReplicateService.getPrediction.mockResolvedValue({ + id: 'pred-1', + status: 'processing', + }); + + const result = await service.checkStatus('gen-1', 'user-1'); + + expect(result.status).toBe('processing'); + expect(result.image).toBeUndefined(); + }); + + it('should return pending generation without polling Replicate', async () => { + const generation = makeGeneration({ status: 'pending' }); + + selectResult.mockResolvedValueOnce([generation]); + + const result = await service.checkStatus('gen-1', 'user-1'); + + expect(result.status).toBe('pending'); + expect(mockReplicateService.getPrediction).not.toHaveBeenCalled(); + }); + }); + + // ── cancelGeneration ─────────────────────────────────────────── + + describe('cancelGeneration', () => { + it('should cancel generation on Replicate and update status', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + + await service.cancelGeneration('gen-1', 'user-1'); + + expect(mockReplicateService.cancelPrediction).toHaveBeenCalledWith('pred-1'); + expect(mockDb.update).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent generation', async () => { + selectResult.mockResolvedValue([]); + + await expect(service.cancelGeneration('non-existent', 'user-1')).rejects.toThrow( + NotFoundException + ); + }); + + it('should throw ForbiddenException when user does not own generation', async () => { + const generation = makeGeneration({ userId: 'user-other' }); + selectResult.mockResolvedValueOnce([generation]); + + await expect(service.cancelGeneration('gen-1', 'user-1')).rejects.toThrow(ForbiddenException); + }); + + it('should no-op for completed generation', async () => { + const generation = makeGeneration({ status: 'completed', completedAt: NOW }); + selectResult.mockResolvedValueOnce([generation]); + + await service.cancelGeneration('gen-1', 'user-1'); + + expect(mockReplicateService.cancelPrediction).not.toHaveBeenCalled(); + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it('should no-op for failed generation', async () => { + const generation = makeGeneration({ status: 'failed', errorMessage: 'Some error' }); + selectResult.mockResolvedValueOnce([generation]); + + await service.cancelGeneration('gen-1', 'user-1'); + + expect(mockReplicateService.cancelPrediction).not.toHaveBeenCalled(); + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it('should cancel pending generation without prediction ID', async () => { + const generation = makeGeneration({ status: 'pending' }); + selectResult.mockResolvedValueOnce([generation]); + + await service.cancelGeneration('gen-1', 'user-1'); + + // No prediction to cancel on Replicate + expect(mockReplicateService.cancelPrediction).not.toHaveBeenCalled(); + // But should still update the DB status + expect(mockDb.update).toHaveBeenCalled(); + }); + + it('should still update status even if Replicate cancel fails', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + mockReplicateService.cancelPrediction.mockRejectedValue(new Error('Network error')); + + await service.cancelGeneration('gen-1', 'user-1'); + + // Should still update status despite Replicate failure + expect(mockDb.update).toHaveBeenCalled(); + }); + }); + + // ── handleWebhook ────────────────────────────────────────────── + + describe('handleWebhook', () => { + it('should process completed webhook and create image', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + + mockStorageService.uploadFromUrl.mockResolvedValue({ + storagePath: 'user-1/generated-gen-1.webp', + publicUrl: 'https://cdn.example.com/images/generated-gen-1.webp', + }); + + // Transaction mock for processCompletedGeneration + const txInsertChain: any = {}; + const txUpdateChain: any = {}; + for (const m of ['from', 'where', 'set', 'values', 'returning', 'limit']) { + txInsertChain[m] = jest.fn().mockReturnValue(txInsertChain); + txUpdateChain[m] = jest.fn().mockReturnValue(txUpdateChain); + } + txInsertChain.then = (r: any) => Promise.resolve().then(r); + txUpdateChain.then = (r: any) => Promise.resolve().then(r); + + mockDb.transaction.mockImplementation((cb: any) => + cb({ + insert: jest.fn().mockReturnValue(txInsertChain), + update: jest.fn().mockReturnValue(txUpdateChain), + }) + ); + + const result = await service.handleWebhook({ + id: 'pred-1', + status: 'succeeded', + output: ['https://replicate.delivery/output.webp'], + }); + + expect(result).toEqual({ received: true }); + expect(mockStorageService.uploadFromUrl).toHaveBeenCalled(); + expect(mockDb.transaction).toHaveBeenCalled(); + }); + + it('should consume credits on webhook success in production', async () => { + buildMockDb(); + createService({ + NODE_ENV: 'production', + WEBHOOK_BASE_URL: 'https://api.example.com', + }); + + const module = await Test.createTestingModule({ + providers: [ + GenerateService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: ReplicateService, useValue: mockReplicateService }, + { provide: StorageService, useValue: mockStorageService }, + { provide: CreditClientService, useValue: mockCreditClient }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => configValues[key]), + }, + }, + ], + }).compile(); + + const prodService = module.get(GenerateService); + + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + + mockStorageService.uploadFromUrl.mockResolvedValue({ + storagePath: 'user-1/generated-gen-1.webp', + publicUrl: 'https://cdn.example.com/images/generated-gen-1.webp', + }); + + const txInsertChain: any = {}; + const txUpdateChain: any = {}; + for (const m of ['from', 'where', 'set', 'values', 'returning', 'limit']) { + txInsertChain[m] = jest.fn().mockReturnValue(txInsertChain); + txUpdateChain[m] = jest.fn().mockReturnValue(txUpdateChain); + } + txInsertChain.then = (r: any) => Promise.resolve().then(r); + txUpdateChain.then = (r: any) => Promise.resolve().then(r); + + mockDb.transaction.mockImplementation((cb: any) => + cb({ + insert: jest.fn().mockReturnValue(txInsertChain), + update: jest.fn().mockReturnValue(txUpdateChain), + }) + ); + + await prodService.handleWebhook({ + id: 'pred-1', + status: 'succeeded', + output: ['https://replicate.delivery/output.webp'], + }); + + expect(mockCreditClient.consumeCredits).toHaveBeenCalledWith( + 'user-1', + 'image_generation', + 10, + expect.stringContaining('gen-1') + ); + }); + + it('should update status to failed on failed webhook', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + + const result = await service.handleWebhook({ + id: 'pred-1', + status: 'failed', + error: 'NSFW content detected', + }); + + expect(result).toEqual({ received: true }); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockStorageService.uploadFromUrl).not.toHaveBeenCalled(); + }); + + it('should return received:false for unknown prediction ID', async () => { + selectResult.mockResolvedValue([]); + + const result = await service.handleWebhook({ + id: 'pred-unknown', + status: 'succeeded', + output: ['https://replicate.delivery/output.webp'], + }); + + expect(result).toEqual({ received: false }); + }); + + it('should return received:false when webhook body has no id', async () => { + const result = await service.handleWebhook({ + status: 'succeeded', + output: ['https://replicate.delivery/output.webp'], + }); + + expect(result).toEqual({ received: false }); + }); + + it('should handle string output format in webhook', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + + mockStorageService.uploadFromUrl.mockResolvedValue({ + storagePath: 'user-1/generated-gen-1.png', + publicUrl: 'https://cdn.example.com/images/generated-gen-1.png', + }); + + const txInsertChain: any = {}; + const txUpdateChain: any = {}; + for (const m of ['from', 'where', 'set', 'values', 'returning', 'limit']) { + txInsertChain[m] = jest.fn().mockReturnValue(txInsertChain); + txUpdateChain[m] = jest.fn().mockReturnValue(txUpdateChain); + } + txInsertChain.then = (r: any) => Promise.resolve().then(r); + txUpdateChain.then = (r: any) => Promise.resolve().then(r); + + mockDb.transaction.mockImplementation((cb: any) => + cb({ + insert: jest.fn().mockReturnValue(txInsertChain), + update: jest.fn().mockReturnValue(txUpdateChain), + }) + ); + + const result = await service.handleWebhook({ + id: 'pred-1', + status: 'succeeded', + output: 'https://replicate.delivery/output.png', + }); + + expect(result).toEqual({ received: true }); + expect(mockStorageService.uploadFromUrl).toHaveBeenCalledWith( + 'https://replicate.delivery/output.png', + 'user-1', + expect.stringContaining('gen-1') + ); + }); + + it('should handle object output format with url property in webhook', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + + mockStorageService.uploadFromUrl.mockResolvedValue({ + storagePath: 'user-1/generated-gen-1.webp', + publicUrl: 'https://cdn.example.com/images/generated-gen-1.webp', + }); + + const txInsertChain: any = {}; + const txUpdateChain: any = {}; + for (const m of ['from', 'where', 'set', 'values', 'returning', 'limit']) { + txInsertChain[m] = jest.fn().mockReturnValue(txInsertChain); + txUpdateChain[m] = jest.fn().mockReturnValue(txUpdateChain); + } + txInsertChain.then = (r: any) => Promise.resolve().then(r); + txUpdateChain.then = (r: any) => Promise.resolve().then(r); + + mockDb.transaction.mockImplementation((cb: any) => + cb({ + insert: jest.fn().mockReturnValue(txInsertChain), + update: jest.fn().mockReturnValue(txUpdateChain), + }) + ); + + const result = await service.handleWebhook({ + id: 'pred-1', + status: 'succeeded', + output: { url: 'https://replicate.delivery/output.webp' }, + }); + + expect(result).toEqual({ received: true }); + expect(mockStorageService.uploadFromUrl).toHaveBeenCalledWith( + 'https://replicate.delivery/output.webp', + 'user-1', + expect.stringContaining('gen-1') + ); + }); + + it('should return received:false on unexpected error during processing', async () => { + const generation = makeGeneration({ + status: 'processing', + replicatePredictionId: 'pred-1', + }); + + selectResult.mockResolvedValueOnce([generation]); + + // uploadFromUrl throws + mockStorageService.uploadFromUrl.mockRejectedValue(new Error('Storage unavailable')); + + const result = await service.handleWebhook({ + id: 'pred-1', + status: 'succeeded', + output: ['https://replicate.delivery/output.webp'], + }); + + // processCompletedGeneration catches the error and updates status to failed, + // then handleWebhook returns received: true since it found the generation + expect(result).toEqual({ received: true }); + }); + }); +}); diff --git a/apps/picture/apps/web/src/app.d.ts b/apps/picture/apps/web/src/app.d.ts index 8e8e79928..ea0e01692 100644 --- a/apps/picture/apps/web/src/app.d.ts +++ b/apps/picture/apps/web/src/app.d.ts @@ -1,9 +1,9 @@ -declare const __BUILD_HASH__: string; -declare const __BUILD_TIME__: string; - // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { + declare const __BUILD_HASH__: string; + declare const __BUILD_TIME__: string; + namespace App { // interface Error {} // interface Locals {} diff --git a/apps/picture/apps/web/src/lib/components/board/ImagePickerModal.svelte b/apps/picture/apps/web/src/lib/components/board/ImagePickerModal.svelte index 645e12e3e..b31af47a4 100644 --- a/apps/picture/apps/web/src/lib/components/board/ImagePickerModal.svelte +++ b/apps/picture/apps/web/src/lib/components/board/ImagePickerModal.svelte @@ -132,7 +132,7 @@ ); - +
diff --git a/apps/picture/apps/web/src/lib/stores/archive.svelte.ts b/apps/picture/apps/web/src/lib/stores/archive.svelte.ts deleted file mode 100644 index 963a54ee5..000000000 --- a/apps/picture/apps/web/src/lib/stores/archive.svelte.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Archive Store - Svelte 5 Runes Version - */ - -import type { Image } from '$lib/api/images'; - -// State using Svelte 5 runes -let archivedImages = $state([]); -let isLoadingArchive = $state(false); -let hasMoreArchive = $state(true); -let currentArchivePage = $state(1); - -export const archiveStore = { - get images() { - return archivedImages; - }, - get isLoading() { - return isLoadingArchive; - }, - get hasMore() { - return hasMoreArchive; - }, - get currentPage() { - return currentArchivePage; - }, - - setImages(images: Image[]) { - archivedImages = images; - }, - - appendImages(images: Image[]) { - archivedImages = [...archivedImages, ...images]; - }, - - addImage(image: Image) { - archivedImages = [image, ...archivedImages]; - }, - - removeImage(id: string) { - archivedImages = archivedImages.filter((img) => img.id !== id); - }, - - setLoading(loading: boolean) { - isLoadingArchive = loading; - }, - - setHasMore(more: boolean) { - hasMoreArchive = more; - }, - - setCurrentPage(page: number) { - currentArchivePage = page; - }, - - incrementPage() { - currentArchivePage++; - }, - - reset() { - archivedImages = []; - isLoadingArchive = false; - hasMoreArchive = true; - currentArchivePage = 1; - }, -}; - -// Export individual getters for backwards compatibility -export function getArchivedImages() { - return archivedImages; -} diff --git a/apps/picture/apps/web/src/lib/stores/boards.svelte.ts b/apps/picture/apps/web/src/lib/stores/boards.svelte.ts deleted file mode 100644 index b4ac266c2..000000000 --- a/apps/picture/apps/web/src/lib/stores/boards.svelte.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Boards Store - Svelte 5 Runes Version - */ - -import type { Board, BoardWithCount } from '$lib/api/boards'; - -// State using Svelte 5 runes -let boards = $state([]); -let currentBoard = $state(null); -let isLoadingBoards = $state(false); -let isLoadingBoard = $state(false); -let currentBoardsPage = $state(1); -let hasBoardsMore = $state(true); -let selectedBoard = $state(null); -let showCreateBoardModal = $state(false); -let showShareBoardModal = $state(false); -let shareBoardId = $state(null); - -// Derived state -const boardSettings = $derived({ - width: currentBoard?.canvasWidth || 2000, - height: currentBoard?.canvasHeight || 1500, - backgroundColor: currentBoard?.backgroundColor || '#ffffff', -}); - -export const boardsStore = { - get boards() { - return boards; - }, - get currentBoard() { - return currentBoard; - }, - get isLoadingBoards() { - return isLoadingBoards; - }, - get isLoadingBoard() { - return isLoadingBoard; - }, - get currentBoardsPage() { - return currentBoardsPage; - }, - get hasBoardsMore() { - return hasBoardsMore; - }, - get selectedBoard() { - return selectedBoard; - }, - get showCreateBoardModal() { - return showCreateBoardModal; - }, - get showShareBoardModal() { - return showShareBoardModal; - }, - get shareBoardId() { - return shareBoardId; - }, - get boardSettings() { - return boardSettings; - }, - - setBoards(newBoards: BoardWithCount[]) { - boards = newBoards; - }, - - appendBoards(newBoards: BoardWithCount[]) { - boards = [...boards, ...newBoards]; - }, - - addBoard(board: BoardWithCount) { - boards = [board, ...boards]; - }, - - updateBoardInList(boardId: string, updates: Partial) { - boards = boards.map((board) => (board.id === boardId ? { ...board, ...updates } : board)); - }, - - removeBoardFromList(boardId: string) { - boards = boards.filter((board) => board.id !== boardId); - }, - - incrementBoardItemCount(boardId: string) { - boards = boards.map((board) => - board.id === boardId ? { ...board, itemCount: board.itemCount + 1 } : board - ); - }, - - decrementBoardItemCount(boardId: string) { - boards = boards.map((board) => - board.id === boardId ? { ...board, itemCount: Math.max(0, board.itemCount - 1) } : board - ); - }, - - setCurrentBoard(board: Board | null) { - currentBoard = board; - }, - - setLoadingBoards(loading: boolean) { - isLoadingBoards = loading; - }, - - setLoadingBoard(loading: boolean) { - isLoadingBoard = loading; - }, - - setCurrentBoardsPage(page: number) { - currentBoardsPage = page; - }, - - setHasBoardsMore(more: boolean) { - hasBoardsMore = more; - }, - - setSelectedBoard(board: Board | null) { - selectedBoard = board; - }, - - setShowCreateBoardModal(show: boolean) { - showCreateBoardModal = show; - }, - - setShowShareBoardModal(show: boolean) { - showShareBoardModal = show; - }, - - setShareBoardId(id: string | null) { - shareBoardId = id; - }, - - resetBoardsState() { - boards = []; - currentBoardsPage = 1; - hasBoardsMore = true; - }, -}; - -// Export individual getters for backwards compatibility -export function getBoards() { - return boards; -} - -export function getCurrentBoard() { - return currentBoard; -} - -export function getBoardSettings() { - return boardSettings; -} diff --git a/apps/picture/apps/web/src/lib/stores/canvas.svelte.ts b/apps/picture/apps/web/src/lib/stores/canvas.svelte.ts deleted file mode 100644 index cb40a39b9..000000000 --- a/apps/picture/apps/web/src/lib/stores/canvas.svelte.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Canvas Store - Svelte 5 Runes Version - */ - -import type { BoardItem, BoardImageItem, BoardTextItem } from '$lib/api/boardItems'; -import { isImageItem, isTextItem } from '$lib/api/boardItems'; - -// Canvas items (images and texts on the board) -let canvasItems = $state([]); - -// Selected items on canvas -let selectedItemIds = $state([]); - -// Canvas view state -let canvasZoom = $state(1); -let canvasPan = $state({ x: 0, y: 0 }); - -// Canvas interaction mode -export type CanvasMode = 'select' | 'pan' | 'draw'; -let canvasMode = $state('select'); - -// Canvas tools -let showGrid = $state(true); -let snapToGrid = $state(false); -let gridSize = $state(20); - -// UI state -let showPropertiesPanel = $state(false); - -// Text editing state -let editingTextId = $state(null); - -// Loading state -let isLoadingCanvasItems = $state(false); - -// History for undo/redo -interface HistoryState { - items: BoardItem[]; - timestamp: number; -} - -let canvasHistory = $state([]); -let canvasHistoryIndex = $state(-1); - -// Derived states -const selectedItems = $derived(canvasItems.filter((item) => selectedItemIds.includes(item.id))); - -const selectedTextItems = $derived(selectedItems.filter(isTextItem)); - -const selectedImageItems = $derived(selectedItems.filter(isImageItem)); - -const hasMixedSelection = $derived(selectedTextItems.length > 0 && selectedImageItems.length > 0); - -const hasSelection = $derived(selectedItemIds.length > 0); - -const isEditingText = $derived(editingTextId !== null); - -const canUndo = $derived(canvasHistoryIndex > 0); - -const canRedo = $derived(canvasHistoryIndex < canvasHistory.length - 1); - -export const canvasStore = { - // Getters - get items() { - return canvasItems; - }, - get selectedItemIds() { - return selectedItemIds; - }, - get selectedItems() { - return selectedItems; - }, - get selectedTextItems() { - return selectedTextItems; - }, - get selectedImageItems() { - return selectedImageItems; - }, - get hasMixedSelection() { - return hasMixedSelection; - }, - get hasSelection() { - return hasSelection; - }, - get zoom() { - return canvasZoom; - }, - get pan() { - return canvasPan; - }, - get mode() { - return canvasMode; - }, - get showGrid() { - return showGrid; - }, - get snapToGrid() { - return snapToGrid; - }, - get gridSize() { - return gridSize; - }, - get showPropertiesPanel() { - return showPropertiesPanel; - }, - get editingTextId() { - return editingTextId; - }, - get isEditingText() { - return isEditingText; - }, - get isLoading() { - return isLoadingCanvasItems; - }, - get canUndo() { - return canUndo; - }, - get canRedo() { - return canRedo; - }, - - // Setters - setItems(items: BoardItem[]) { - canvasItems = items; - }, - - setLoading(loading: boolean) { - isLoadingCanvasItems = loading; - }, - - setMode(mode: CanvasMode) { - canvasMode = mode; - }, - - setShowGrid(show: boolean) { - showGrid = show; - }, - - setSnapToGrid(snap: boolean) { - snapToGrid = snap; - }, - - setGridSize(size: number) { - gridSize = size; - }, - - setShowPropertiesPanel(show: boolean) { - showPropertiesPanel = show; - }, - - // Item management - addItem(item: BoardItem) { - canvasItems = [...canvasItems, item]; - saveToHistory(); - }, - - updateItem(id: string, updates: Partial) { - canvasItems = canvasItems.map((item) => - item.id === id ? ({ ...item, ...updates } as BoardItem) : item - ); - saveToHistory(); - }, - - removeItem(id: string) { - canvasItems = canvasItems.filter((item) => item.id !== id); - selectedItemIds = selectedItemIds.filter((itemId) => itemId !== id); - saveToHistory(); - }, - - removeSelectedItems() { - const ids = selectedItemIds; - canvasItems = canvasItems.filter((item) => !ids.includes(item.id)); - selectedItemIds = []; - saveToHistory(); - }, - - // Selection management - selectItem(id: string, multi = false) { - if (multi) { - if (selectedItemIds.includes(id)) { - selectedItemIds = selectedItemIds.filter((itemId) => itemId !== id); - } else { - selectedItemIds = [...selectedItemIds, id]; - } - } else { - selectedItemIds = [id]; - } - }, - - selectAll() { - selectedItemIds = canvasItems.map((item) => item.id); - }, - - deselectAll() { - selectedItemIds = []; - }, - - // Text editing - startEditingText(id: string) { - editingTextId = id; - }, - - stopEditingText() { - editingTextId = null; - }, - - // Z-index management - bringToFront(id: string) { - const maxZIndex = Math.max(...canvasItems.map((item) => item.zIndex)); - canvasStore.updateItem(id, { zIndex: maxZIndex + 1 }); - }, - - sendToBack(id: string) { - const minZIndex = Math.min(...canvasItems.map((item) => item.zIndex)); - canvasStore.updateItem(id, { zIndex: minZIndex - 1 }); - }, - - moveForward(id: string) { - const item = canvasItems.find((i) => i.id === id); - if (!item) return; - - const itemsAbove = canvasItems.filter((i) => i.zIndex > item.zIndex); - if (itemsAbove.length === 0) return; - - const nextZIndex = Math.min(...itemsAbove.map((i) => i.zIndex)); - canvasStore.updateItem(id, { zIndex: nextZIndex + 0.5 }); - }, - - moveBackward(id: string) { - const item = canvasItems.find((i) => i.id === id); - if (!item) return; - - const itemsBelow = canvasItems.filter((i) => i.zIndex < item.zIndex); - if (itemsBelow.length === 0) return; - - const prevZIndex = Math.max(...itemsBelow.map((i) => i.zIndex)); - canvasStore.updateItem(id, { zIndex: prevZIndex - 0.5 }); - }, - - // Zoom functions - zoomIn() { - canvasZoom = Math.min(canvasZoom * 1.2, 5); - }, - - zoomOut() { - canvasZoom = Math.max(canvasZoom / 1.2, 0.1); - }, - - setZoom(zoom: number) { - canvasZoom = zoom; - }, - - setPan(pan: { x: number; y: number }) { - canvasPan = pan; - }, - - zoomToFit( - containerWidth: number, - containerHeight: number, - boardWidth: number, - boardHeight: number - ) { - const scaleX = containerWidth / boardWidth; - const scaleY = containerHeight / boardHeight; - const scale = Math.min(scaleX, scaleY) * 0.9; - canvasZoom = scale; - canvasPan = { x: 0, y: 0 }; - }, - - resetZoom() { - canvasZoom = 1; - canvasPan = { x: 0, y: 0 }; - }, - - // History management - undo() { - if (canvasHistoryIndex <= 0) return; - - const prevState = canvasHistory[canvasHistoryIndex - 1]; - canvasItems = JSON.parse(JSON.stringify(prevState.items)); - canvasHistoryIndex--; - }, - - redo() { - if (canvasHistoryIndex >= canvasHistory.length - 1) return; - - const nextState = canvasHistory[canvasHistoryIndex + 1]; - canvasItems = JSON.parse(JSON.stringify(nextState.items)); - canvasHistoryIndex++; - }, - - clearHistory() { - canvasHistory = []; - canvasHistoryIndex = -1; - }, - - // Reset - reset() { - canvasItems = []; - selectedItemIds = []; - canvasZoom = 1; - canvasPan = { x: 0, y: 0 }; - canvasMode = 'select'; - editingTextId = null; - canvasStore.clearHistory(); - }, - - // Grid snapping - snapPositionToGrid(x: number, y: number): { x: number; y: number } { - if (!snapToGrid) return { x, y }; - return { - x: Math.round(x / gridSize) * gridSize, - y: Math.round(y / gridSize) * gridSize, - }; - }, -}; - -// Internal helper -function saveToHistory() { - // Remove any history after current index - const newHistory = canvasHistory.slice(0, canvasHistoryIndex + 1); - - // Add current state - newHistory.push({ - items: JSON.parse(JSON.stringify(canvasItems)), - timestamp: Date.now(), - }); - - // Limit history to 50 states - if (newHistory.length > 50) { - newHistory.shift(); - } - - canvasHistory = newHistory; - canvasHistoryIndex = newHistory.length - 1; -} - -// Export for backwards compatibility -export function getCanvasItems() { - return canvasItems; -} - -export function getSelectedItemIds() { - return selectedItemIds; -} - -export function getCanvasZoom() { - return canvasZoom; -} diff --git a/apps/picture/apps/web/src/lib/stores/contextMenu.svelte.ts b/apps/picture/apps/web/src/lib/stores/contextMenu.svelte.ts deleted file mode 100644 index e8c3ecb97..000000000 --- a/apps/picture/apps/web/src/lib/stores/contextMenu.svelte.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Context Menu Store - Svelte 5 Runes Version - */ - -import type { Image } from '$lib/api/images'; - -interface ContextMenuState { - visible: boolean; - x: number; - y: number; - image: Image | null; - showTagSubmenu: boolean; - submenuX: number; - submenuY: number; -} - -const initialState: ContextMenuState = { - visible: false, - x: 0, - y: 0, - image: null, - showTagSubmenu: false, - submenuX: 0, - submenuY: 0, -}; - -let contextMenuState = $state({ ...initialState }); - -export const contextMenuStore = { - get state() { - return contextMenuState; - }, - get visible() { - return contextMenuState.visible; - }, - get x() { - return contextMenuState.x; - }, - get y() { - return contextMenuState.y; - }, - get image() { - return contextMenuState.image; - }, - get showTagSubmenu() { - return contextMenuState.showTagSubmenu; - }, - get submenuX() { - return contextMenuState.submenuX; - }, - get submenuY() { - return contextMenuState.submenuY; - }, - - show(x: number, y: number, image: Image) { - contextMenuState = { - visible: true, - x, - y, - image, - showTagSubmenu: false, - submenuX: 0, - submenuY: 0, - }; - }, - - hide() { - contextMenuState = { ...initialState }; - }, - - openTagSubmenu(x: number, y: number) { - contextMenuState = { - ...contextMenuState, - showTagSubmenu: true, - submenuX: x, - submenuY: y, - }; - }, - - hideTagSubmenu() { - contextMenuState = { - ...contextMenuState, - showTagSubmenu: false, - }; - }, -}; - -// Export for backwards compatibility -export function showContextMenu(x: number, y: number, image: Image) { - contextMenuStore.show(x, y, image); -} - -export function hideContextMenu() { - contextMenuStore.hide(); -} - -export function showTagSubmenu(x: number, y: number) { - contextMenuStore.openTagSubmenu(x, y); -} - -export function hideTagSubmenu() { - contextMenuStore.hideTagSubmenu(); -} - -export function getContextMenu() { - return contextMenuState; -} - -// Re-export for compatibility -export { contextMenuState as contextMenu }; diff --git a/apps/picture/apps/web/src/lib/stores/explore.svelte.ts b/apps/picture/apps/web/src/lib/stores/explore.svelte.ts deleted file mode 100644 index f02151800..000000000 --- a/apps/picture/apps/web/src/lib/stores/explore.svelte.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Explore Store - Svelte 5 Runes Version - */ - -import type { Image } from '$lib/api/images'; - -// State using Svelte 5 runes -let exploreImages = $state([]); -let isLoadingExplore = $state(false); -let hasMoreExplore = $state(true); -let currentExplorePage = $state(1); -let exploreSortBy = $state<'recent' | 'popular' | 'trending'>('recent'); -let exploreSearchQuery = $state(''); -let showExploreFavoritesOnly = $state(false); - -export const exploreStore = { - get images() { - return exploreImages; - }, - get isLoading() { - return isLoadingExplore; - }, - get hasMore() { - return hasMoreExplore; - }, - get currentPage() { - return currentExplorePage; - }, - get sortBy() { - return exploreSortBy; - }, - get searchQuery() { - return exploreSearchQuery; - }, - get showFavoritesOnly() { - return showExploreFavoritesOnly; - }, - - setImages(images: Image[]) { - exploreImages = images; - }, - - appendImages(images: Image[]) { - exploreImages = [...exploreImages, ...images]; - }, - - setLoading(loading: boolean) { - isLoadingExplore = loading; - }, - - setHasMore(more: boolean) { - hasMoreExplore = more; - }, - - setCurrentPage(page: number) { - currentExplorePage = page; - }, - - incrementPage() { - currentExplorePage++; - }, - - setSortBy(sort: 'recent' | 'popular' | 'trending') { - exploreSortBy = sort; - // Reset when changing sort - exploreImages = []; - currentExplorePage = 1; - hasMoreExplore = true; - }, - - setSearchQuery(query: string) { - exploreSearchQuery = query; - // Reset when changing search - exploreImages = []; - currentExplorePage = 1; - hasMoreExplore = true; - }, - - setShowFavoritesOnly(favoritesOnly: boolean) { - showExploreFavoritesOnly = favoritesOnly; - // Reset when changing filter - exploreImages = []; - currentExplorePage = 1; - hasMoreExplore = true; - }, - - reset() { - exploreImages = []; - isLoadingExplore = false; - hasMoreExplore = true; - currentExplorePage = 1; - exploreSortBy = 'recent'; - exploreSearchQuery = ''; - showExploreFavoritesOnly = false; - }, -}; - -// Export individual getters for backwards compatibility -export function getExploreImages() { - return exploreImages; -} - -export function getExploreSortBy() { - return exploreSortBy; -} diff --git a/apps/picture/apps/web/src/lib/stores/generate.svelte.ts b/apps/picture/apps/web/src/lib/stores/generate.svelte.ts deleted file mode 100644 index f3f096662..000000000 --- a/apps/picture/apps/web/src/lib/stores/generate.svelte.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Generate Store - Svelte 5 Runes Version - */ - -// State using Svelte 5 runes -let isGenerating = $state(false); -let generationProgress = $state(''); -let generationError = $state(''); -let currentGenerationId = $state(null); - -export const generateStore = { - get isGenerating() { - return isGenerating; - }, - get generationProgress() { - return generationProgress; - }, - get generationError() { - return generationError; - }, - get currentGenerationId() { - return currentGenerationId; - }, - - startGeneration(generationId?: string) { - isGenerating = true; - generationProgress = 'Starting...'; - generationError = ''; - currentGenerationId = generationId || null; - }, - - updateProgress(progress: string) { - generationProgress = progress; - }, - - setError(error: string) { - generationError = error; - isGenerating = false; - }, - - completeGeneration() { - isGenerating = false; - generationProgress = 'Complete!'; - currentGenerationId = null; - }, - - cancelGeneration() { - isGenerating = false; - generationProgress = ''; - generationError = ''; - currentGenerationId = null; - }, - - reset() { - isGenerating = false; - generationProgress = ''; - generationError = ''; - currentGenerationId = null; - }, -}; - -// Export individual getters for backwards compatibility -export function getIsGenerating() { - return isGenerating; -} - -export function getGenerationProgress() { - return generationProgress; -} - -export function getGenerationError() { - return generationError; -} diff --git a/apps/picture/apps/web/src/lib/stores/images.svelte.ts b/apps/picture/apps/web/src/lib/stores/images.svelte.ts deleted file mode 100644 index 09ba7a3b2..000000000 --- a/apps/picture/apps/web/src/lib/stores/images.svelte.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Images Store - Svelte 5 Runes Version - */ - -import type { Image } from '$lib/api/images'; - -// State using Svelte 5 runes -let images = $state([]); -let selectedImage = $state(null); -let isLoading = $state(false); -let hasMore = $state(true); -let currentPage = $state(1); -let showFavoritesOnly = $state(false); - -export const imagesStore = { - get images() { - return images; - }, - get selectedImage() { - return selectedImage; - }, - get isLoading() { - return isLoading; - }, - get hasMore() { - return hasMore; - }, - get currentPage() { - return currentPage; - }, - get showFavoritesOnly() { - return showFavoritesOnly; - }, - - setImages(newImages: Image[]) { - images = newImages; - }, - - appendImages(newImages: Image[]) { - images = [...images, ...newImages]; - }, - - addImage(image: Image) { - images = [image, ...images]; - }, - - updateImage(id: string, updates: Partial) { - images = images.map((img) => (img.id === id ? { ...img, ...updates } : img)); - }, - - removeImage(id: string) { - images = images.filter((img) => img.id !== id); - if (selectedImage?.id === id) { - selectedImage = null; - } - }, - - selectImage(image: Image | null) { - selectedImage = image; - }, - - setLoading(loading: boolean) { - isLoading = loading; - }, - - setHasMore(more: boolean) { - hasMore = more; - }, - - setCurrentPage(page: number) { - currentPage = page; - }, - - incrementPage() { - currentPage++; - }, - - setShowFavoritesOnly(favoritesOnly: boolean) { - showFavoritesOnly = favoritesOnly; - }, - - reset() { - images = []; - selectedImage = null; - isLoading = false; - hasMore = true; - currentPage = 1; - }, -}; - -// Export individual getters for backwards compatibility -export function getImages() { - return images; -} - -export function getSelectedImage() { - return selectedImage; -} - -export function getIsLoading() { - return isLoading; -} diff --git a/apps/picture/apps/web/src/lib/stores/models.svelte.ts b/apps/picture/apps/web/src/lib/stores/models.svelte.ts deleted file mode 100644 index da7c35b4f..000000000 --- a/apps/picture/apps/web/src/lib/stores/models.svelte.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Models Store - Svelte 5 Runes Version - */ - -import type { Model } from '$lib/api/models'; - -// State using Svelte 5 runes -let models = $state([]); -let selectedModel = $state(null); -let isLoadingModels = $state(false); - -export const modelsStore = { - get models() { - return models; - }, - get selectedModel() { - return selectedModel; - }, - get isLoadingModels() { - return isLoadingModels; - }, - - setModels(newModels: Model[]) { - models = newModels; - // Auto-select default model if no model selected - if (!selectedModel && newModels.length > 0) { - const defaultModel = newModels.find((m) => m.isDefault) || newModels[0]; - selectedModel = defaultModel; - } - }, - - selectModel(model: Model | null) { - selectedModel = model; - }, - - selectModelById(id: string) { - const model = models.find((m) => m.id === id); - if (model) { - selectedModel = model; - } - }, - - setLoading(loading: boolean) { - isLoadingModels = loading; - }, - - reset() { - models = []; - selectedModel = null; - isLoadingModels = false; - }, -}; - -// Export individual getters for backwards compatibility -export function getModels() { - return models; -} - -export function getSelectedModel() { - return selectedModel; -} diff --git a/apps/picture/apps/web/src/lib/stores/sidebar.svelte.ts b/apps/picture/apps/web/src/lib/stores/sidebar.svelte.ts deleted file mode 100644 index 154b42011..000000000 --- a/apps/picture/apps/web/src/lib/stores/sidebar.svelte.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Sidebar Store - Svelte 5 Runes Version - */ - -import { browser } from '$app/environment'; - -const SIDEBAR_KEY = 'picture_sidebar_collapsed'; - -function loadInitialState(): boolean { - if (!browser) return false; - const saved = localStorage.getItem(SIDEBAR_KEY); - return saved === 'true'; -} - -let isSidebarCollapsed = $state(loadInitialState()); - -export const sidebarStore = { - get isCollapsed() { - return isSidebarCollapsed; - }, - - toggle() { - isSidebarCollapsed = !isSidebarCollapsed; - if (browser) { - localStorage.setItem(SIDEBAR_KEY, String(isSidebarCollapsed)); - } - }, - - setCollapsed(collapsed: boolean) { - isSidebarCollapsed = collapsed; - if (browser) { - localStorage.setItem(SIDEBAR_KEY, String(collapsed)); - } - }, - - expand() { - isSidebarCollapsed = false; - if (browser) { - localStorage.setItem(SIDEBAR_KEY, 'false'); - } - }, - - collapse() { - isSidebarCollapsed = true; - if (browser) { - localStorage.setItem(SIDEBAR_KEY, 'true'); - } - }, -}; - -// Export for backwards compatibility -export function getIsSidebarCollapsed() { - return isSidebarCollapsed; -} - -export function toggleSidebar() { - sidebarStore.toggle(); -} - -export function setSidebarCollapsed(collapsed: boolean) { - sidebarStore.setCollapsed(collapsed); -} - -// Re-export the writable-like interface for backward compatibility -export { isSidebarCollapsed }; diff --git a/apps/picture/apps/web/src/lib/stores/tags.svelte.ts b/apps/picture/apps/web/src/lib/stores/tags.svelte.ts deleted file mode 100644 index a5be9ed09..000000000 --- a/apps/picture/apps/web/src/lib/stores/tags.svelte.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Tags Store - Svelte 5 Runes Version - */ - -import type { Tag } from '$lib/api/tags'; - -// State using Svelte 5 runes -let tags = $state([]); -let selectedTags = $state([]); -let isLoadingTags = $state(false); - -export const tagsStore = { - get tags() { - return tags; - }, - get selectedTags() { - return selectedTags; - }, - get isLoadingTags() { - return isLoadingTags; - }, - - setTags(newTags: Tag[]) { - tags = newTags; - }, - - addTag(tag: Tag) { - tags = [...tags, tag]; - }, - - updateTag(id: string, updates: Partial) { - tags = tags.map((tag) => (tag.id === id ? { ...tag, ...updates } : tag)); - }, - - removeTag(id: string) { - tags = tags.filter((tag) => tag.id !== id); - selectedTags = selectedTags.filter((tagId) => tagId !== id); - }, - - selectTag(tagId: string) { - if (!selectedTags.includes(tagId)) { - selectedTags = [...selectedTags, tagId]; - } - }, - - deselectTag(tagId: string) { - selectedTags = selectedTags.filter((id) => id !== tagId); - }, - - toggleTag(tagId: string) { - if (selectedTags.includes(tagId)) { - selectedTags = selectedTags.filter((id) => id !== tagId); - } else { - selectedTags = [...selectedTags, tagId]; - } - }, - - setSelectedTags(tagIds: string[]) { - selectedTags = tagIds; - }, - - clearSelectedTags() { - selectedTags = []; - }, - - setLoading(loading: boolean) { - isLoadingTags = loading; - }, - - reset() { - tags = []; - selectedTags = []; - isLoadingTags = false; - }, -}; - -// Export individual getters for backwards compatibility -export function getTags() { - return tags; -} - -export function getSelectedTags() { - return selectedTags; -} diff --git a/apps/picture/apps/web/src/lib/stores/ui.svelte.ts b/apps/picture/apps/web/src/lib/stores/ui.svelte.ts deleted file mode 100644 index d5899112e..000000000 --- a/apps/picture/apps/web/src/lib/stores/ui.svelte.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * UI Store - Svelte 5 Runes Version - */ - -import { browser } from '$app/environment'; - -const UI_VISIBLE_KEY = 'picture_ui_visible'; - -function loadInitialState(): boolean { - if (!browser) return true; - const saved = localStorage.getItem(UI_VISIBLE_KEY); - return saved !== 'false'; // Default to true -} - -let isUIVisible = $state(loadInitialState()); -let showKeyboardShortcuts = $state(false); - -export const uiStore = { - get isVisible() { - return isUIVisible; - }, - get showKeyboardShortcuts() { - return showKeyboardShortcuts; - }, - - toggle() { - isUIVisible = !isUIVisible; - if (browser) { - localStorage.setItem(UI_VISIBLE_KEY, String(isUIVisible)); - } - }, - - setVisible(visible: boolean) { - isUIVisible = visible; - if (browser) { - localStorage.setItem(UI_VISIBLE_KEY, String(visible)); - } - }, - - show() { - isUIVisible = true; - if (browser) { - localStorage.setItem(UI_VISIBLE_KEY, 'true'); - } - }, - - hide() { - isUIVisible = false; - if (browser) { - localStorage.setItem(UI_VISIBLE_KEY, 'false'); - } - }, - - setShowKeyboardShortcuts(show: boolean) { - showKeyboardShortcuts = show; - }, - - toggleKeyboardShortcuts() { - showKeyboardShortcuts = !showKeyboardShortcuts; - }, -}; - -// Export for backwards compatibility -export function toggleUI() { - uiStore.toggle(); -} - -export function getIsUIVisible() { - return isUIVisible; -} - -// Re-export for compatibility -export { isUIVisible, showKeyboardShortcuts }; diff --git a/apps/picture/apps/web/src/lib/stores/view.svelte.ts b/apps/picture/apps/web/src/lib/stores/view.svelte.ts deleted file mode 100644 index b22db77d6..000000000 --- a/apps/picture/apps/web/src/lib/stores/view.svelte.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * View Store - Svelte 5 Runes Version - */ - -import { browser } from '$app/environment'; - -export type ViewMode = 'single' | 'grid3' | 'grid5'; - -const VIEW_MODE_KEY = 'picture_view_mode'; - -function loadInitialViewMode(): ViewMode { - if (!browser) { - return 'grid3'; - } - const saved = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null; - return saved || 'grid3'; -} - -let viewMode = $state(loadInitialViewMode()); - -export const viewStore = { - get mode() { - return viewMode; - }, - - set(mode: ViewMode) { - viewMode = mode; - if (browser) { - localStorage.setItem(VIEW_MODE_KEY, mode); - } - }, - - cycle() { - const modes: ViewMode[] = ['single', 'grid3', 'grid5']; - const currentIndex = modes.indexOf(viewMode); - const nextMode = modes[(currentIndex + 1) % modes.length]; - viewStore.set(nextMode); - }, - - setSingle() { - viewStore.set('single'); - }, - - setGrid3() { - viewStore.set('grid3'); - }, - - setGrid5() { - viewStore.set('grid5'); - }, -}; - -// Export for backwards compatibility -export function setViewMode(mode: ViewMode) { - viewStore.set(mode); -} - -export function cycleViewMode() { - viewStore.cycle(); -} - -export function getViewMode() { - return viewMode; -} - -// Re-export for compatibility -export { viewMode }; diff --git a/apps/picture/apps/web/src/routes/app/board/+page.svelte b/apps/picture/apps/web/src/routes/app/board/+page.svelte index 174da2b48..14ab772e4 100644 --- a/apps/picture/apps/web/src/routes/app/board/+page.svelte +++ b/apps/picture/apps/web/src/routes/app/board/+page.svelte @@ -280,7 +280,7 @@
- showCreateBoardModal.set(false)}> + showCreateBoardModal.set(false)}>

Neues Board erstellen

@@ -339,7 +339,7 @@ - (showDeleteModal = false)}> + (showDeleteModal = false)}>

Board löschen?

diff --git a/apps/picture/apps/web/vite.config.ts b/apps/picture/apps/web/vite.config.ts index dcf66a534..f830d5535 100644 --- a/apps/picture/apps/web/vite.config.ts +++ b/apps/picture/apps/web/vite.config.ts @@ -7,8 +7,8 @@ import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite export default defineConfig({ plugins: [ - tailwindcss(), - sveltekit(), + tailwindcss() as any, + sveltekit() as any, SvelteKitPWA( createPWAConfig({ name: 'Picture - KI Bildgenerator', @@ -16,7 +16,7 @@ export default defineConfig({ description: 'KI-gestützte Bildgenerierung', themeColor: '#ec4899', }) - ), + ) as any, ], server: { port: 5175,