feat(manadeck): migrate stores to backend API and remove audio features

- Refactor mobile stores to use backend API instead of Supabase direct
- Add card-progress and study-session repositories
- Expand API controller with progress tracking endpoints
- Remove AudioRecorder and AudioCard components
- Remove TTS service and Supabase direct access
- Update web stores for consistency

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-27 14:47:14 +01:00
parent cb5657579b
commit 14aace00c2
31 changed files with 2392 additions and 1609 deletions

View file

@ -1,21 +1,22 @@
# Server Configuration
NODE_ENV=development
PORT=8080
# ManaDeck Backend Environment Variables
# Mana Core Configuration
# PostgreSQL Database (using manadeck-database package)
DATABASE_URL=postgresql://manadeck:manadeck_dev_password@localhost:5433/manadeck
# Mana Core Service (Required)
MANA_SERVICE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app
APP_ID=your-app-id-from-mana
MANA_SUPABASE_SECRET_KEY=your-service-key-from-mana-core # REQUIRED for credit operations
APP_ID=cea4bfc6-a4de-4e17-91e2-54275940156e
MANA_SUPABASE_SECRET_KEY=your-service-key
# Signup Redirect URL (Optional)
SIGNUP_REDIRECT_URL=https://manadeck.com/welcome
# PostgreSQL Database (Drizzle ORM)
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/manadeck
# Google Gemini API (for AI deck generation)
GOOGLE_GENAI_API_KEY=your-gemini-api-key
# AI Services (Google Gemini)
GOOGLE_GENAI_API_KEY=your-google-genai-api-key # Get from https://aistudio.google.com/apikey
# Server Configuration
PORT=3000
NODE_ENV=development
# JWT Configuration
JWT_SECRET=your-jwt-secret
# CORS Configuration
FRONTEND_URL=http://localhost:8081
# CORS (Optional)
FRONTEND_URL=http://localhost:5173

View file

@ -17,6 +17,8 @@ import {
CardRepository,
UserStatsRepository,
DeckTemplateRepository,
StudySessionRepository,
CardProgressRepository,
} from './database';
@Module({
@ -72,6 +74,8 @@ import {
CardRepository,
UserStatsRepository,
DeckTemplateRepository,
StudySessionRepository,
CardProgressRepository,
],
})
export class AppModule implements NestModule {

View file

@ -6,6 +6,7 @@ import {
Delete,
Body,
Param,
Query,
UseGuards,
Logger,
BadRequestException,
@ -19,7 +20,13 @@ import {
getCreditCost,
getOperationDescription,
} from '../config/credit-operations';
import { DeckRepository, CardRepository, UserStatsRepository } from '../database';
import {
DeckRepository,
CardRepository,
UserStatsRepository,
StudySessionRepository,
CardProgressRepository,
} from '../database';
import { AiService, CardType } from '../services/ai.service';
@Controller('api')
@ -32,6 +39,8 @@ export class ApiController {
private readonly deckRepository: DeckRepository,
private readonly cardRepository: CardRepository,
private readonly userStatsRepository: UserStatsRepository,
private readonly studySessionRepository: StudySessionRepository,
private readonly cardProgressRepository: CardProgressRepository,
private readonly aiService: AiService,
) {}
@ -363,6 +372,54 @@ export class ApiController {
};
}
@Get('decks/:id')
async getDeck(@CurrentUser() user: any, @Param('id') deckId: string) {
this.logger.log(`Getting deck ${deckId} for user: ${user.sub}`);
const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'deck_not_found',
message: 'Deck not found or you do not have permission to view it',
});
}
// Get card count
const cardCount = await this.cardRepository.countByDeckId(deckId);
return {
userId: user.sub,
deck: {
...deck,
card_count: cardCount,
},
};
}
@Get('decks/:id/cards')
async getDeckCards(@CurrentUser() user: any, @Param('id') deckId: string) {
this.logger.log(`Getting cards for deck ${deckId}, user: ${user.sub}`);
// Verify deck ownership
const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'deck_not_found',
message: 'Deck not found or you do not have permission to view it',
});
}
const cards = await this.cardRepository.findByDeckId(deckId);
return {
userId: user.sub,
deckId,
cards,
count: cards.length,
};
}
@Get('cards')
async getUserCards(@CurrentUser() user: any) {
this.logger.log(`Getting cards for user: ${user.sub}`);
@ -374,6 +431,34 @@ export class ApiController {
};
}
@Get('cards/:id')
async getCard(@CurrentUser() user: any, @Param('id') cardId: string) {
this.logger.log(`Getting card ${cardId} for user: ${user.sub}`);
const card = await this.cardRepository.findById(cardId);
if (!card) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found',
});
}
// Verify ownership via deck
const deck = await this.deckRepository.findByIdAndUserId(card.deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found or you do not have permission to view it',
});
}
return {
userId: user.sub,
card,
};
}
@Post('cards')
async createCard(@CurrentUser() user: any, @Body() cardData: any) {
this.logger.log(`Creating card for user: ${user.sub}`);
@ -404,6 +489,106 @@ export class ApiController {
};
}
@Put('cards/:id')
async updateCard(
@CurrentUser() user: any,
@Param('id') cardId: string,
@Body() cardData: any,
) {
this.logger.log(`Updating card ${cardId} for user: ${user.sub}`);
// Get the card first
const existingCard = await this.cardRepository.findById(cardId);
if (!existingCard) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found',
});
}
// Verify ownership via deck
const deck = await this.deckRepository.findByIdAndUserId(existingCard.deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found or you do not have permission to update it',
});
}
const updatedCard = await this.cardRepository.update(cardId, {
title: cardData.title,
content: cardData.content,
cardType: cardData.cardType,
position: cardData.position,
isFavorite: cardData.isFavorite,
});
return {
success: true,
userId: user.sub,
card: updatedCard,
};
}
@Delete('cards/:id')
async deleteCard(@CurrentUser() user: any, @Param('id') cardId: string) {
this.logger.log(`Deleting card ${cardId} for user: ${user.sub}`);
// Get the card first
const existingCard = await this.cardRepository.findById(cardId);
if (!existingCard) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found',
});
}
// Verify ownership via deck
const deck = await this.deckRepository.findByIdAndUserId(existingCard.deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found or you do not have permission to delete it',
});
}
await this.cardRepository.delete(cardId);
return {
success: true,
userId: user.sub,
cardId,
};
}
@Post('cards/reorder')
async reorderCards(
@CurrentUser() user: any,
@Body() reorderData: { deckId: string; cardIds: string[] },
) {
this.logger.log(`Reordering cards in deck ${reorderData.deckId} for user: ${user.sub}`);
// Verify deck ownership
const deck = await this.deckRepository.findByIdAndUserId(reorderData.deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'deck_not_found',
message: 'Deck not found or you do not have permission to modify it',
});
}
// Update positions
for (let i = 0; i < reorderData.cardIds.length; i++) {
await this.cardRepository.update(reorderData.cardIds[i], { position: i });
}
return {
success: true,
userId: user.sub,
deckId: reorderData.deckId,
};
}
@Get('stats')
async getUserStats(@CurrentUser() user: any) {
this.logger.log(`Getting stats for user: ${user.sub}`);
@ -423,4 +608,386 @@ export class ApiController {
},
};
}
// ============ Study Sessions ============
@Get('study-sessions')
async getStudySessions(@CurrentUser() user: any) {
this.logger.log(`Getting study sessions for user: ${user.sub}`);
const sessions = await this.studySessionRepository.findByUserId(user.sub);
return {
userId: user.sub,
sessions,
count: sessions.length,
};
}
@Get('study-sessions/stats')
async getStudySessionStats(@CurrentUser() user: any) {
this.logger.log(`Getting study session stats for user: ${user.sub}`);
const stats = await this.studySessionRepository.getStatsByUserId(user.sub);
return {
userId: user.sub,
stats,
};
}
@Get('study-sessions/range')
async getStudySessionsByDateRange(
@CurrentUser() user: any,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
) {
this.logger.log(`Getting study sessions for user ${user.sub} from ${startDate} to ${endDate}`);
if (!startDate || !endDate) {
throw new BadRequestException({
error: 'validation_failed',
message: 'startDate and endDate query parameters are required',
});
}
const sessions = await this.studySessionRepository.findByDateRange(
user.sub,
new Date(startDate),
new Date(endDate),
);
return {
userId: user.sub,
sessions,
count: sessions.length,
startDate,
endDate,
};
}
@Get('study-sessions/deck/:deckId')
async getStudySessionsByDeck(
@CurrentUser() user: any,
@Param('deckId') deckId: string,
) {
this.logger.log(`Getting study sessions for deck ${deckId}, user: ${user.sub}`);
// Verify deck ownership
const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'deck_not_found',
message: 'Deck not found or you do not have permission to view it',
});
}
const sessions = await this.studySessionRepository.findByDeckId(deckId, user.sub);
return {
userId: user.sub,
deckId,
sessions,
count: sessions.length,
};
}
@Get('study-sessions/:id')
async getStudySession(@CurrentUser() user: any, @Param('id') sessionId: string) {
this.logger.log(`Getting study session ${sessionId} for user: ${user.sub}`);
const session = await this.studySessionRepository.findById(sessionId);
if (!session || session.userId !== user.sub) {
throw new BadRequestException({
error: 'session_not_found',
message: 'Study session not found',
});
}
return {
userId: user.sub,
session,
};
}
@Post('study-sessions')
async createStudySession(@CurrentUser() user: any, @Body() sessionData: any) {
this.logger.log(`Creating study session for user: ${user.sub}`);
// Verify deck ownership
const deck = await this.deckRepository.findByIdAndUserId(sessionData.deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'deck_not_found',
message: 'Deck not found or you do not have permission to create sessions for it',
});
}
const session = await this.studySessionRepository.create({
userId: user.sub,
deckId: sessionData.deckId,
startedAt: sessionData.startedAt || new Date(),
endedAt: sessionData.endedAt,
totalCards: sessionData.totalCards || 0,
completedCards: sessionData.completedCards || 0,
correctCards: sessionData.correctCards || 0,
timeSpentSeconds: sessionData.timeSpentSeconds || 0,
});
return {
success: true,
userId: user.sub,
session,
};
}
@Put('study-sessions/:id')
async updateStudySession(
@CurrentUser() user: any,
@Param('id') sessionId: string,
@Body() sessionData: any,
) {
this.logger.log(`Updating study session ${sessionId} for user: ${user.sub}`);
const session = await this.studySessionRepository.update(sessionId, user.sub, {
endedAt: sessionData.endedAt,
totalCards: sessionData.totalCards,
completedCards: sessionData.completedCards,
correctCards: sessionData.correctCards,
timeSpentSeconds: sessionData.timeSpentSeconds,
});
if (!session) {
throw new BadRequestException({
error: 'session_not_found',
message: 'Study session not found or you do not have permission to update it',
});
}
return {
success: true,
userId: user.sub,
session,
};
}
// ============ Card Progress ============
@Get('progress')
async getCardProgress(@CurrentUser() user: any) {
this.logger.log(`Getting card progress for user: ${user.sub}`);
const progress = await this.cardProgressRepository.findByUserId(user.sub);
return {
userId: user.sub,
progress,
count: progress.length,
};
}
@Get('progress/stats')
async getCardProgressStats(@CurrentUser() user: any) {
this.logger.log(`Getting card progress stats for user: ${user.sub}`);
const stats = await this.cardProgressRepository.getStatsByUserId(user.sub);
return {
userId: user.sub,
stats,
};
}
@Get('progress/due')
async getDueCards(@CurrentUser() user: any) {
this.logger.log(`Getting due cards for user: ${user.sub}`);
const dueProgress = await this.cardProgressRepository.findDueCards(user.sub);
return {
userId: user.sub,
progress: dueProgress,
count: dueProgress.length,
};
}
@Get('progress/deck/:deckId')
async getCardProgressByDeck(
@CurrentUser() user: any,
@Param('deckId') deckId: string,
) {
this.logger.log(`Getting card progress for deck ${deckId}, user: ${user.sub}`);
// Verify deck ownership
const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'deck_not_found',
message: 'Deck not found or you do not have permission to view it',
});
}
const progress = await this.cardProgressRepository.findByDeckId(deckId, user.sub);
return {
userId: user.sub,
deckId,
progress,
count: progress.length,
};
}
@Get('progress/deck/:deckId/due')
async getDueCardsByDeck(
@CurrentUser() user: any,
@Param('deckId') deckId: string,
) {
this.logger.log(`Getting due cards for deck ${deckId}, user: ${user.sub}`);
// Verify deck ownership
const deck = await this.deckRepository.findByIdAndUserId(deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'deck_not_found',
message: 'Deck not found or you do not have permission to view it',
});
}
const dueProgress = await this.cardProgressRepository.findDueCards(user.sub, deckId);
return {
userId: user.sub,
deckId,
progress: dueProgress,
count: dueProgress.length,
};
}
@Get('progress/card/:cardId')
async getCardProgressByCard(
@CurrentUser() user: any,
@Param('cardId') cardId: string,
) {
this.logger.log(`Getting progress for card ${cardId}, user: ${user.sub}`);
// Verify card ownership via deck
const card = await this.cardRepository.findById(cardId);
if (!card) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found',
});
}
const deck = await this.deckRepository.findByIdAndUserId(card.deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found or you do not have permission to view it',
});
}
const progress = await this.cardProgressRepository.findByCardId(cardId, user.sub);
return {
userId: user.sub,
cardId,
progress,
};
}
@Post('progress')
async upsertCardProgress(@CurrentUser() user: any, @Body() progressData: any) {
this.logger.log(`Upserting card progress for user: ${user.sub}`);
// Verify card ownership via deck
const card = await this.cardRepository.findById(progressData.cardId);
if (!card) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found',
});
}
const deck = await this.deckRepository.findByIdAndUserId(card.deckId, user.sub);
if (!deck) {
throw new BadRequestException({
error: 'card_not_found',
message: 'Card not found or you do not have permission to update progress for it',
});
}
const progress = await this.cardProgressRepository.upsert({
userId: user.sub,
cardId: progressData.cardId,
easeFactor: progressData.easeFactor ?? 2.5,
interval: progressData.interval ?? 0,
repetitions: progressData.repetitions ?? 0,
lastReviewed: progressData.lastReviewed ? new Date(progressData.lastReviewed) : new Date(),
nextReview: progressData.nextReview ? new Date(progressData.nextReview) : new Date(),
status: progressData.status || 'new',
});
return {
success: true,
userId: user.sub,
progress,
};
}
// ============ AI Operations ============
@Post('ai/generate-from-image')
async generateCardsFromImage(@CurrentUser() user: any, @Body() body: any) {
this.logger.log(`Generating cards from image for user: ${user.sub}`);
const { image, context, cardCount = 5 } = body;
if (!image) {
throw new BadRequestException({
error: 'validation_failed',
message: 'image (base64) is required',
});
}
const result = await this.aiService.generateFromImage(image, context, cardCount);
if (!isOk(result)) {
throw new BadRequestException({
error: 'ai_generation_failed',
message: result.error.message,
});
}
return {
success: true,
userId: user.sub,
cards: result.value.cards.map((card) => ({
type: card.cardType,
content: card.content,
metadata: {
confidence: 1,
source: 'ai-image',
tags: [],
},
})),
metadata: result.value.metadata,
};
}
@Post('ai/enhance-content')
async enhanceCardContent(@CurrentUser() user: any, @Body() body: any) {
this.logger.log(`Enhancing card content for user: ${user.sub}`);
const { content, cardType } = body;
if (!content) {
throw new BadRequestException({
error: 'validation_failed',
message: 'content is required',
});
}
const result = await this.aiService.enhanceContent(content, cardType || 'flashcard');
if (!isOk(result)) {
throw new BadRequestException({
error: 'ai_enhancement_failed',
message: result.error.message,
});
}
return {
success: true,
userId: user.sub,
enhancedContent: result.value.enhancedContent,
};
}
}

View file

@ -0,0 +1,127 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
cardProgress,
cards,
type CardProgress,
type NewCardProgress,
eq,
and,
lte,
sql,
} from '@manacore/manadeck-database';
@Injectable()
export class CardProgressRepository {
private readonly logger = new Logger(CardProgressRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async findByUserId(userId: string): Promise<CardProgress[]> {
this.logger.debug(`Finding card progress for user: ${userId}`);
return this.db
.select()
.from(cardProgress)
.where(eq(cardProgress.userId, userId));
}
async findByDeckId(deckId: string, userId: string): Promise<CardProgress[]> {
// Join with cards to filter by deck
const result = await this.db
.select({ progress: cardProgress })
.from(cardProgress)
.innerJoin(cards, eq(cardProgress.cardId, cards.id))
.where(and(eq(cards.deckId, deckId), eq(cardProgress.userId, userId)));
return result.map((r) => r.progress);
}
async findByCardId(cardId: string, userId: string): Promise<CardProgress | null> {
const result = await this.db
.select()
.from(cardProgress)
.where(and(eq(cardProgress.cardId, cardId), eq(cardProgress.userId, userId)))
.limit(1);
return result[0] || null;
}
async findDueCards(userId: string, deckId?: string): Promise<CardProgress[]> {
const now = new Date();
if (deckId) {
const result = await this.db
.select({ progress: cardProgress })
.from(cardProgress)
.innerJoin(cards, eq(cardProgress.cardId, cards.id))
.where(
and(
eq(cardProgress.userId, userId),
eq(cards.deckId, deckId),
lte(cardProgress.nextReview, now)
)
);
return result.map((r) => r.progress);
}
return this.db
.select()
.from(cardProgress)
.where(and(eq(cardProgress.userId, userId), lte(cardProgress.nextReview, now)));
}
async create(data: NewCardProgress): Promise<CardProgress> {
this.logger.debug(`Creating card progress for card: ${data.cardId}`);
const result = await this.db.insert(cardProgress).values(data).returning();
return result[0];
}
async upsert(data: NewCardProgress): Promise<CardProgress> {
this.logger.debug(`Upserting card progress for card: ${data.cardId}`);
const result = await this.db
.insert(cardProgress)
.values(data)
.onConflictDoUpdate({
target: [cardProgress.userId, cardProgress.cardId],
set: {
easeFactor: data.easeFactor,
interval: data.interval,
repetitions: data.repetitions,
lastReviewed: data.lastReviewed,
nextReview: data.nextReview,
status: data.status,
updatedAt: new Date(),
},
})
.returning();
return result[0];
}
async update(
id: string,
data: Partial<Omit<NewCardProgress, 'id' | 'userId' | 'cardId' | 'createdAt'>>
): Promise<CardProgress | null> {
this.logger.debug(`Updating card progress: ${id}`);
const result = await this.db
.update(cardProgress)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(cardProgress.id, id))
.returning();
return result[0] || null;
}
async getStatsByUserId(userId: string) {
const result = await this.db
.select({
totalCards: sql<number>`count(*)::int`,
newCards: sql<number>`count(*) filter (where ${cardProgress.status} = 'new')::int`,
learningCards: sql<number>`count(*) filter (where ${cardProgress.status} = 'learning')::int`,
reviewCards: sql<number>`count(*) filter (where ${cardProgress.status} = 'review')::int`,
avgEaseFactor: sql<string>`avg(${cardProgress.easeFactor})`,
})
.from(cardProgress)
.where(eq(cardProgress.userId, userId));
return result[0];
}
}

View file

@ -2,3 +2,5 @@ export { DeckRepository } from './deck.repository';
export { CardRepository } from './card.repository';
export { UserStatsRepository } from './user-stats.repository';
export { DeckTemplateRepository } from './deck-template.repository';
export { StudySessionRepository } from './study-session.repository';
export { CardProgressRepository } from './card-progress.repository';

View file

@ -0,0 +1,98 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
studySessions,
type StudySession,
type NewStudySession,
eq,
and,
desc,
gte,
lte,
sql,
} from '@manacore/manadeck-database';
@Injectable()
export class StudySessionRepository {
private readonly logger = new Logger(StudySessionRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async findByUserId(userId: string, limit = 50): Promise<StudySession[]> {
this.logger.debug(`Finding study sessions for user: ${userId}`);
return this.db
.select()
.from(studySessions)
.where(eq(studySessions.userId, userId))
.orderBy(desc(studySessions.startedAt))
.limit(limit);
}
async findByDeckId(deckId: string, userId: string): Promise<StudySession[]> {
return this.db
.select()
.from(studySessions)
.where(and(eq(studySessions.deckId, deckId), eq(studySessions.userId, userId)))
.orderBy(desc(studySessions.startedAt));
}
async findByDateRange(
userId: string,
startDate: Date,
endDate: Date
): Promise<StudySession[]> {
return this.db
.select()
.from(studySessions)
.where(
and(
eq(studySessions.userId, userId),
gte(studySessions.startedAt, startDate),
lte(studySessions.startedAt, endDate)
)
)
.orderBy(desc(studySessions.startedAt));
}
async findById(id: string): Promise<StudySession | null> {
const result = await this.db
.select()
.from(studySessions)
.where(eq(studySessions.id, id))
.limit(1);
return result[0] || null;
}
async create(data: NewStudySession): Promise<StudySession> {
this.logger.debug(`Creating study session for deck: ${data.deckId}`);
const result = await this.db.insert(studySessions).values(data).returning();
return result[0];
}
async update(
id: string,
userId: string,
data: Partial<Omit<NewStudySession, 'id' | 'userId' | 'deckId' | 'startedAt'>>
): Promise<StudySession | null> {
this.logger.debug(`Updating study session: ${id}`);
const result = await this.db
.update(studySessions)
.set(data)
.where(and(eq(studySessions.id, id), eq(studySessions.userId, userId)))
.returning();
return result[0] || null;
}
async getStatsByUserId(userId: string) {
const result = await this.db
.select({
totalSessions: sql<number>`count(*)::int`,
totalCardsStudied: sql<number>`sum(${studySessions.completedCards})::int`,
totalCorrectCards: sql<number>`sum(${studySessions.correctCards})::int`,
totalTimeSeconds: sql<number>`sum(${studySessions.timeSpentSeconds})::int`,
})
.from(studySessions)
.where(eq(studySessions.userId, userId));
return result[0];
}
}

View file

@ -238,6 +238,122 @@ Ensure variety in the questions and good coverage of the subject matter.`;
return `- Mix of ${cardTypes.join(', ')} cards as appropriate for the content`;
}
/**
* Generate cards from an image using Gemini Vision
*/
async generateFromImage(
imageBase64: string,
context?: string,
cardCount: number = 5,
): AsyncResult<DeckGenerationData> {
const startTime = Date.now();
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
try {
const prompt = `Analyze this image and create ${cardCount} educational flashcards based on its content.
${context ? `Context: ${context}` : ''}
For each concept, term, or important element you identify in the image, create a flashcard or quiz question.
Return the cards as a JSON object with a "cards" array containing objects with:
- cardType: "flashcard" or "quiz"
- title: short title
- content: { front, back, hint } for flashcards OR { question, options, correctAnswer, explanation } for quiz`;
const response = await this.ai.models.generateContent({
model: this.model,
contents: [
{
role: 'user',
parts: [
{ text: prompt },
{
inlineData: {
mimeType: 'image/jpeg',
data: imageBase64,
},
},
],
},
],
config: {
responseMimeType: 'application/json',
},
});
const generationTime = Date.now() - startTime;
const responseText = response.text?.trim();
if (!responseText) {
return err(ServiceError.generationFailed('Google Gemini', 'Empty response from AI'));
}
const parsed = JSON.parse(responseText);
const cards: GeneratedCard[] = parsed.cards || [];
this.logger.log(`Generated ${cards.length} cards from image in ${generationTime}ms`);
return ok({
cards,
metadata: {
model: this.model,
tokensUsed: response.usageMetadata?.totalTokenCount,
generationTime,
},
});
} catch (error) {
this.logger.error('AI image generation failed:', error);
return err(
ServiceError.generationFailed(
'Google Gemini',
error instanceof Error ? error.message : 'Unknown error',
),
);
}
}
/**
* Enhance card content using AI
*/
async enhanceContent(
content: string,
cardType: string,
): AsyncResult<{ enhancedContent: string }> {
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
try {
const prompt = `Improve and enhance this ${cardType} card content. Make it clearer, more educational, and engaging.
Original content:
${content}
Return the enhanced content in the same JSON format as the input, but improved.`;
const response = await this.ai.models.generateContent({
model: this.model,
contents: prompt,
config: {
responseMimeType: 'application/json',
},
});
const responseText = response.text?.trim();
if (!responseText) {
return ok({ enhancedContent: content });
}
return ok({ enhancedContent: responseText });
} catch (error) {
this.logger.error('AI content enhancement failed:', error);
return ok({ enhancedContent: content }); // Return original on failure
}
}
private buildResponseSchema(cardTypes: CardType[]): any {
const cardSchemas: any[] = [];

View file

@ -0,0 +1,8 @@
# ManaDeck Mobile App Environment Variables
# Backend API URL
EXPO_PUBLIC_BACKEND_URL=http://localhost:3000
# Mana Core Auth
EXPO_PUBLIC_MANA_MIDDLEWARE_URL=https://api.manacore.de
EXPO_PUBLIC_MIDDLEWARE_APP_ID=manadeck

View file

@ -10,7 +10,6 @@ import { CardView } from '../../../components/card/CardView';
import { Button } from '../../../components/ui/Button';
import { Card as UICard } from '../../../components/ui/Card';
import { QuizContent } from '../../../store/cardStore';
import { AudioCard } from '../../../components/study/AudioCard';
import { useThemeColors } from '../../../utils/themeUtils';
export default function StudySessionScreen() {
@ -34,7 +33,6 @@ export default function StudySessionScreen() {
const colors = useThemeColors();
const [sessionStarted, setSessionStarted] = useState(false);
const [audioEnabled, setAudioEnabled] = useState(false);
useEffect(() => {
if (deckId && !sessionStarted) {
@ -118,22 +116,7 @@ export default function StudySessionScreen() {
</Pressable>
),
headerRight: () => (
<View style={{ marginRight: spacing.content.small, flexDirection: 'row', alignItems: 'center' }}>
<Pressable
onPress={() => setAudioEnabled(!audioEnabled)}
style={({ pressed }) => ({
marginRight: 12,
borderRadius: 20,
backgroundColor: colors.muted,
padding: 8,
opacity: pressed ? 0.7 : 1
})}>
<Icon
name={audioEnabled ? 'volume-high' : 'volume-mute'}
size={20}
color={audioEnabled ? colors.primary : colors.mutedForeground}
/>
</Pressable>
<View style={{ marginRight: 16, flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontWeight: '500', color: colors.foreground }}>
{currentCardIndex + 1}/{sessionCards.length}
</Text>
@ -201,13 +184,6 @@ export default function StudySessionScreen() {
) : (
<CardView card={currentCard} mode="study" />
)}
{/* Audio Controls */}
{audioEnabled && (
<View style={{ marginTop: spacing.lg }}>
<AudioCard card={currentCard} autoPlay={true} showControls={true} />
</View>
)}
</View>
{/* Spacer to push buttons to bottom */}

View file

@ -1,207 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { View, Pressable, Animated } from 'react-native';
import { Text } from '../ui/Text';
import { Icon } from '../ui/Icon';
import { Audio } from 'expo-av';
import { useAIStore } from '../../store/aiStore';
import { Card } from '../ui/Card';
interface AudioRecorderProps {
onRecordingComplete?: (audioUri: string) => void;
onTranscriptionComplete?: (text: string) => void;
}
export const AudioRecorder: React.FC<AudioRecorderProps> = ({
onRecordingComplete,
onTranscriptionComplete,
}) => {
const { audioRecording, startRecording, stopRecording, generateCardsFromAudio } = useAIStore();
const [recordingDuration, setRecordingDuration] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const pulseAnim = useRef(new Animated.Value(1)).current;
const durationInterval = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (audioRecording.isRecording) {
// Start pulse animation
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.2,
duration: 500,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}),
])
).start();
// Start duration counter
durationInterval.current = setInterval(() => {
setRecordingDuration((prev) => prev + 1);
}, 1000);
} else {
// Stop animations and reset
pulseAnim.stopAnimation();
pulseAnim.setValue(1);
if (durationInterval.current) {
clearInterval(durationInterval.current);
durationInterval.current = null;
}
}
return () => {
if (durationInterval.current) {
clearInterval(durationInterval.current);
}
};
}, [audioRecording.isRecording, pulseAnim]);
const handleStartRecording = async () => {
try {
setRecordingDuration(0);
await startRecording();
} catch (error) {
console.error('Error starting recording:', error);
}
};
const handleStopRecording = async () => {
try {
setIsProcessing(true);
const uri = await stopRecording();
setRecordingDuration(0);
if (onRecordingComplete) {
onRecordingComplete(uri);
}
// Generate cards from audio
if (onTranscriptionComplete) {
try {
const cards = await generateCardsFromAudio(uri);
// Extract text from first card for transcription callback
const transcribedText = cards.length > 0 ? JSON.stringify(cards[0].content) : '';
onTranscriptionComplete(transcribedText);
} catch (error) {
console.error('Error transcribing audio:', error);
}
}
} catch (error) {
console.error('Error stopping recording:', error);
} finally {
setIsProcessing(false);
}
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<Card padding="lg" variant="elevated">
<View className="items-center">
{/* Recording Status */}
<View className="mb-4 items-center">
{audioRecording.isRecording ? (
<>
<Text variant="h4" className="mb-2 font-semibold text-red-600">
Aufnahme läuft
</Text>
<Text variant="h3" className="font-bold text-gray-900">
{formatDuration(recordingDuration)}
</Text>
</>
) : isProcessing ? (
<>
<Text variant="h4" className="mb-2 font-semibold text-blue-600">
Verarbeite Audio...
</Text>
<View className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
</>
) : (
<Text variant="h4" className="font-semibold text-gray-700">
Drücke zum Aufnehmen
</Text>
)}
</View>
{/* Recording Button */}
<Pressable
onPress={audioRecording.isRecording ? handleStopRecording : handleStartRecording}
disabled={isProcessing}
className="relative">
<Animated.View
style={{
transform: [{ scale: pulseAnim }],
}}
className={`h-20 w-20 items-center justify-center rounded-full ${
audioRecording.isRecording ? 'bg-red-500' : 'bg-blue-500'
} ${isProcessing ? 'opacity-50' : ''}`}>
<Icon
name={audioRecording.isRecording ? 'stop' : 'mic'}
size={32}
color="white"
library="Ionicons"
/>
</Animated.View>
{/* Pulse Effect Ring */}
{audioRecording.isRecording && (
<Animated.View
style={{
position: 'absolute',
top: -10,
left: -10,
right: -10,
bottom: -10,
borderRadius: 50,
borderWidth: 2,
borderColor: '#EF4444',
opacity: pulseAnim.interpolate({
inputRange: [1, 1.2],
outputRange: [0.3, 0],
}),
transform: [{ scale: pulseAnim }],
}}
/>
)}
</Pressable>
{/* Instructions */}
<Text variant="caption" className="mt-4 text-center text-gray-500">
{audioRecording.isRecording
? 'Spreche deutlich und drücke Stopp wenn fertig'
: isProcessing
? 'Audio wird mit KI verarbeitet...'
: 'Halte das Mikrofon gedrückt und spreche deinen Lerninhalt'}
</Text>
{/* Audio Waveform Visualization (simplified) */}
{audioRecording.isRecording && (
<View className="mt-4 flex-row items-center justify-center space-x-1">
{[...Array(7)].map((_, i) => (
<Animated.View
key={i}
className="w-1 bg-red-500"
style={{
height: 20 + Math.random() * 20,
opacity: pulseAnim.interpolate({
inputRange: [1, 1.2],
outputRange: [0.5, 1],
}),
}}
/>
))}
</View>
)}
</View>
</Card>
);
};

View file

@ -14,7 +14,6 @@ import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { useAIStore } from '../../store/aiStore';
import { GeneratedCard, GenerationOptions } from '../../utils/supabaseAIService';
import { AudioRecorder } from './AudioRecorder';
import { ImageCardCreator } from './ImageCardCreator';
interface SmartCardCreatorProps {
@ -32,7 +31,7 @@ export const SmartCardCreator: React.FC<SmartCardCreatorProps> = ({ deckId, onCa
saveGeneratedCards,
} = useAIStore();
const [inputMode, setInputMode] = useState<'text' | 'voice' | 'image'>('text');
const [inputMode, setInputMode] = useState<'text' | 'image'>('text');
const [textInput, setTextInput] = useState('');
const [options, setOptions] = useState<GenerationOptions>({
cardTypes: ['flashcard', 'quiz'],
@ -186,7 +185,6 @@ export const SmartCardCreator: React.FC<SmartCardCreatorProps> = ({ deckId, onCa
<View className="mb-4 flex-row space-x-2">
{[
{ key: 'text', label: 'Text', icon: 'text' },
{ key: 'voice', label: 'Sprache', icon: 'mic' },
{ key: 'image', label: 'Bild', icon: 'image' },
].map((mode) => (
<Pressable
@ -230,17 +228,6 @@ export const SmartCardCreator: React.FC<SmartCardCreatorProps> = ({ deckId, onCa
</Card>
)}
{inputMode === 'voice' && (
<View className="mb-4">
<AudioRecorder
onTranscriptionComplete={(text) => {
setTextInput(text);
setInputMode('text');
}}
/>
</View>
)}
{inputMode === 'image' && (
<View className="mb-4">
<ImageCardCreator

View file

@ -1,134 +0,0 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Pressable } from 'react-native';
import { Icon } from '../ui/Icon';
import { TTSService } from '../../utils/ttsService';
import { Card as CardType } from '../../store/cardStore';
interface AudioCardProps {
card: CardType;
autoPlay?: boolean;
showControls?: boolean;
}
export const AudioCard: React.FC<AudioCardProps> = ({
card,
autoPlay = false,
showControls = true,
}) => {
const [isPlaying, setIsPlaying] = useState(false);
const [rate, setRate] = useState(1.0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (autoPlay) {
handlePlay();
}
return () => {
// Stop any ongoing speech when component unmounts
TTSService.stop();
};
}, [card.id]);
const handlePlay = async () => {
try {
setError(null);
setIsPlaying(true);
await TTSService.speakCard(card);
setIsPlaying(false);
} catch (error) {
console.error('Error playing audio:', error);
setError('Fehler beim Abspielen');
setIsPlaying(false);
}
};
const handleStop = async () => {
try {
await TTSService.stop();
setIsPlaying(false);
} catch (error) {
console.error('Error stopping audio:', error);
}
};
const handlePause = async () => {
try {
await TTSService.pause();
setIsPlaying(false);
} catch (error) {
console.error('Error pausing audio:', error);
}
};
const handleResume = async () => {
try {
await TTSService.resume();
setIsPlaying(true);
} catch (error) {
console.error('Error resuming audio:', error);
}
};
const adjustRate = (newRate: number) => {
setRate(newRate);
// Rate will be applied on next play
};
if (!showControls) {
return null;
}
return (
<View className="flex-row items-center justify-center space-x-2 rounded-lg bg-gray-50 p-3">
{/* Play/Pause Button */}
<Pressable
onPress={isPlaying ? handlePause : handlePlay}
className="rounded-full bg-blue-500 p-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name={isPlaying ? 'pause' : 'play'} size={24} color="white" library="Ionicons" />
</Pressable>
{/* Stop Button */}
{isPlaying && (
<Pressable
onPress={handleStop}
className="rounded-full bg-gray-400 p-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="stop" size={24} color="white" library="Ionicons" />
</Pressable>
)}
{/* Speed Controls */}
<View className="ml-4 flex-row items-center space-x-2">
<Text className="text-sm text-gray-600">Geschwindigkeit:</Text>
<Pressable
onPress={() => adjustRate(0.75)}
className={`rounded px-2 py-1 ${rate === 0.75 ? 'bg-blue-500' : 'bg-gray-200'}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text className={`text-xs ${rate === 0.75 ? 'text-white' : 'text-gray-700'}`}>0.75x</Text>
</Pressable>
<Pressable
onPress={() => adjustRate(1.0)}
className={`rounded px-2 py-1 ${rate === 1.0 ? 'bg-blue-500' : 'bg-gray-200'}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text className={`text-xs ${rate === 1.0 ? 'text-white' : 'text-gray-700'}`}>1x</Text>
</Pressable>
<Pressable
onPress={() => adjustRate(1.25)}
className={`rounded px-2 py-1 ${rate === 1.25 ? 'bg-blue-500' : 'bg-gray-200'}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text className={`text-xs ${rate === 1.25 ? 'text-white' : 'text-gray-700'}`}>1.25x</Text>
</Pressable>
</View>
{/* Error Display */}
{error && <Text className="ml-2 text-xs text-red-500">{error}</Text>}
</View>
);
};

View file

@ -24,12 +24,10 @@
"@react-native-google-signin/google-signin": "^14.0.2",
"@react-native-segmented-control/segmented-control": "2.5.7",
"@react-navigation/native": "^7.0.3",
"@supabase/supabase-js": "^2.81.1",
"base64-js": "^1.5.1",
"class-variance-authority": "^0.7.1",
"expo": "54.0.13",
"expo-apple-authentication": "~8.0.7",
"expo-av": "~16.0.7",
"expo-blur": "~15.0.7",
"expo-build-properties": "~1.0.9",
"expo-constants": "~18.0.9",
@ -41,7 +39,6 @@
"expo-linking": "~8.0.8",
"expo-router": "~6.0.10",
"expo-secure-store": "^15.0.7",
"expo-speech": "~14.0.7",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7",

View file

@ -0,0 +1,365 @@
/**
* API Client for ManaDeck Backend
* Replaces direct Supabase access with backend API calls
*/
import { authService } from './authService';
const API_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3000';
interface ApiResponse<T> {
data: T | null;
error: string | null;
}
class ApiClient {
private async getAuthHeaders(): Promise<Record<string, string>> {
const token = await authService.getAppToken();
if (!token) {
throw new Error('Not authenticated');
}
// Check if token is valid, try to refresh if not
if (!authService.isTokenValidLocally(token)) {
const refreshToken = await authService.getRefreshToken();
if (refreshToken) {
try {
await authService.refreshTokens(refreshToken);
const newToken = await authService.getAppToken();
if (newToken) {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${newToken}`,
};
}
} catch (error) {
console.error('Failed to refresh token:', error);
throw new Error('Session expired. Please sign in again.');
}
}
throw new Error('Session expired. Please sign in again.');
}
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
};
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
try {
const headers = await this.getAuthHeaders();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
...headers,
...options.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: errorData.message || `Request failed with status ${response.status}`,
};
}
const data = await response.json();
return { data, error: null };
} catch (error: any) {
console.error(`API Error [${endpoint}]:`, error);
return {
data: null,
error: error.message || 'Network error',
};
}
}
// ============ Profile ============
async getProfile() {
return this.request<{ user: any; credits: number }>('/api/profile');
}
async getCreditBalance() {
return this.request<{ balance: number }>('/api/credits/balance');
}
// ============ Decks ============
async getDecks() {
const response = await this.request<{ decks: any[]; count: number }>('/api/decks');
return response;
}
async getDeck(id: string) {
return this.request<{ deck: any }>(`/api/decks/${id}`);
}
async createDeck(deckData: {
title: string;
description?: string;
coverImageUrl?: string;
isPublic?: boolean;
tags?: string[];
settings?: Record<string, any>;
metadata?: Record<string, any>;
}) {
return this.request<{ deck: any; creditsUsed: number }>('/api/decks', {
method: 'POST',
body: JSON.stringify(deckData),
});
}
async updateDeck(
id: string,
updates: {
title?: string;
description?: string;
coverImageUrl?: string;
isPublic?: boolean;
tags?: string[];
settings?: Record<string, any>;
metadata?: Record<string, any>;
}
) {
return this.request<{ deck: any }>(`/api/decks/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
async deleteDeck(id: string) {
return this.request<{ success: boolean }>(`/api/decks/${id}`, {
method: 'DELETE',
});
}
async generateDeckWithAI(requestData: {
prompt: string;
deckTitle: string;
deckDescription?: string;
cardCount?: number;
cardTypes?: string[];
difficulty?: string;
tags?: string[];
language?: string;
}) {
return this.request<{
deck: any;
cards: any[];
cardCount: number;
creditsUsed: number;
metadata: any;
}>('/api/decks/generate', {
method: 'POST',
body: JSON.stringify(requestData),
});
}
// ============ Cards ============
async getDeckCards(deckId: string) {
return this.request<{ cards: any[]; count: number }>(`/api/decks/${deckId}/cards`);
}
async getCard(id: string) {
return this.request<{ card: any }>(`/api/cards/${id}`);
}
async createCard(cardData: {
deckId: string;
title?: string;
content: any;
cardType?: string;
position?: number;
aiModel?: string;
aiPrompt?: string;
}) {
return this.request<{ card: any }>('/api/cards', {
method: 'POST',
body: JSON.stringify(cardData),
});
}
async updateCard(
id: string,
updates: {
title?: string;
content?: any;
cardType?: string;
position?: number;
isFavorite?: boolean;
}
) {
return this.request<{ card: any }>(`/api/cards/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
async deleteCard(id: string) {
return this.request<{ success: boolean }>(`/api/cards/${id}`, {
method: 'DELETE',
});
}
async reorderCards(deckId: string, cardIds: string[]) {
return this.request<{ success: boolean }>('/api/cards/reorder', {
method: 'POST',
body: JSON.stringify({ deckId, cardIds }),
});
}
// ============ Stats ============
async getStats() {
return this.request<{
stats: {
totalDecks: number;
totalCards: number;
totalSessions: number;
totalCardsStudied: number;
streakDays: number;
};
}>('/api/stats');
}
// ============ Study Sessions ============
async getStudySessions() {
return this.request<{ sessions: any[]; count: number }>('/api/study-sessions');
}
async getStudySession(id: string) {
return this.request<{ session: any }>(`/api/study-sessions/${id}`);
}
async getStudySessionsByDeck(deckId: string) {
return this.request<{ sessions: any[]; count: number }>(
`/api/study-sessions/deck/${deckId}`
);
}
async getStudySessionsByDateRange(startDate: string, endDate: string) {
return this.request<{ sessions: any[]; count: number }>(
`/api/study-sessions/range?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`
);
}
async getStudySessionStats() {
return this.request<{
stats: {
totalSessions: number;
totalCardsStudied: number;
totalCorrectCards: number;
totalTimeSeconds: number;
};
}>('/api/study-sessions/stats');
}
async createStudySession(sessionData: {
deckId: string;
startedAt?: string;
endedAt?: string;
totalCards?: number;
completedCards?: number;
correctCards?: number;
timeSpentSeconds?: number;
}) {
return this.request<{ session: any }>('/api/study-sessions', {
method: 'POST',
body: JSON.stringify(sessionData),
});
}
async updateStudySession(
id: string,
updates: {
endedAt?: string;
totalCards?: number;
completedCards?: number;
correctCards?: number;
timeSpentSeconds?: number;
}
) {
return this.request<{ session: any }>(`/api/study-sessions/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
// ============ Card Progress ============
async getCardProgress() {
return this.request<{ progress: any[]; count: number }>('/api/progress');
}
async getCardProgressByDeck(deckId: string) {
return this.request<{ progress: any[]; count: number }>(
`/api/progress/deck/${deckId}`
);
}
async getCardProgressByCard(cardId: string) {
return this.request<{ progress: any | null }>(`/api/progress/card/${cardId}`);
}
async getCardProgressStats() {
return this.request<{
stats: {
totalCards: number;
newCards: number;
learningCards: number;
reviewCards: number;
avgEaseFactor: string;
};
}>('/api/progress/stats');
}
async getDueCards() {
return this.request<{ progress: any[]; count: number }>('/api/progress/due');
}
async getDueCardsByDeck(deckId: string) {
return this.request<{ progress: any[]; count: number }>(
`/api/progress/deck/${deckId}/due`
);
}
async upsertCardProgress(progressData: {
cardId: string;
easeFactor?: number;
interval?: number;
repetitions?: number;
lastReviewed?: string;
nextReview?: string;
status?: 'new' | 'learning' | 'review';
}) {
return this.request<{ progress: any }>('/api/progress', {
method: 'POST',
body: JSON.stringify(progressData),
});
}
// ============ AI Operations ============
async generateCardsFromImage(imageBase64: string, context?: string, cardCount?: number) {
return this.request<{
cards: Array<{
type: string;
content: any;
metadata: { confidence: number; source: string; tags: string[] };
}>;
}>('/api/ai/generate-from-image', {
method: 'POST',
body: JSON.stringify({ image: imageBase64, context, cardCount }),
});
}
async enhanceContent(content: string, cardType: string) {
return this.request<{ enhancedContent: string }>('/api/ai/enhance-content', {
method: 'POST',
body: JSON.stringify({ content, cardType }),
});
}
}
export const apiClient = new ApiClient();

View file

@ -4,7 +4,8 @@ import {
GeneratedCard,
GenerationOptions,
} from '../utils/supabaseAIService';
import { supabase } from '../utils/supabase';
import { apiClient } from '../services/apiClient';
import { authService } from '../services/authService';
import { Card } from './cardStore';
interface AIState {
@ -16,23 +17,13 @@ interface AIState {
tokensUsed: number;
cost: number;
};
audioRecording: {
isRecording: boolean;
uri: string | null;
duration: number;
};
// Actions
generateCardsFromText: (text: string, options?: GenerationOptions) => Promise<GeneratedCard[]>;
generateCardsFromAudio: (audioUri: string) => Promise<GeneratedCard[]>;
generateCardsFromImage: (imageUri: string, context?: string) => Promise<GeneratedCard[]>;
enhanceCard: (card: Card) => Promise<Card>;
generateRelatedCards: (card: Card) => Promise<GeneratedCard[]>;
// Audio Recording
startRecording: () => Promise<void>;
stopRecording: () => Promise<string>;
// Utility
clearGeneratedCards: () => void;
saveGeneratedCards: (deckId: string, cards: GeneratedCard[]) => Promise<void>;
@ -48,11 +39,6 @@ export const useAIStore = create<AIState>((set, get) => ({
tokensUsed: 0,
cost: 0,
},
audioRecording: {
isRecording: false,
uri: null,
duration: 0,
},
// Generate cards from text
generateCardsFromText: async (text: string, options?: GenerationOptions) => {
@ -79,37 +65,6 @@ export const useAIStore = create<AIState>((set, get) => ({
}
},
// Generate cards from audio
generateCardsFromAudio: async (audioUri: string) => {
set({ isGenerating: true, error: null });
try {
// Read audio file and convert to base64
const { FileSystem } = await import('expo-file-system');
const audioBase64 = await FileSystem.readAsStringAsync(audioUri, {
encoding: FileSystem.EncodingType.Base64,
});
const cards = await AIService.generateCardsFromSpeech(audioBase64);
set((state) => ({
generatedCards: [...state.generatedCards, ...cards],
isGenerating: false,
}));
// Track usage (estimated for Whisper + GPT)
get().trackUsage(1000, 0.02);
return cards;
} catch (error: any) {
set({
error: error.message || 'Fehler beim Verarbeiten der Audioaufnahme',
isGenerating: false,
});
throw error;
}
},
// Generate cards from image
generateCardsFromImage: async (imageUri: string, context?: string) => {
set({ isGenerating: true, error: null });
@ -194,110 +149,34 @@ export const useAIStore = create<AIState>((set, get) => ({
}
},
// Audio Recording
startRecording: async () => {
try {
const { Audio } = await import('expo-av');
// Request permissions
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') {
throw new Error('Mikrofonzugriff verweigert');
}
// Configure audio
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
});
// Create and start recording
const recording = new Audio.Recording();
await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
await recording.startAsync();
// Store recording instance (we'll need to manage this differently in production)
(global as any).currentRecording = recording;
set({
audioRecording: {
isRecording: true,
uri: null,
duration: 0,
},
});
} catch (error) {
console.error('Error starting recording:', error);
throw error;
}
},
stopRecording: async () => {
try {
const recording = (global as any).currentRecording;
if (!recording) {
throw new Error('Keine aktive Aufnahme');
}
await recording.stopAndUnloadAsync();
const uri = recording.getURI();
if (!uri) {
throw new Error('Keine Aufnahme-URI erhalten');
}
// Clean up
delete (global as any).currentRecording;
set({
audioRecording: {
isRecording: false,
uri,
duration: 0,
},
});
return uri;
} catch (error) {
console.error('Error stopping recording:', error);
throw error;
}
},
// Clear generated cards
clearGeneratedCards: () => {
set({ generatedCards: [], error: null });
},
// Save generated cards to deck
// Save generated cards to deck via API
saveGeneratedCards: async (deckId: string, cards: GeneratedCard[]) => {
try {
const {
data: { user },
} = await supabase.auth.getUser();
// Get user from auth service
const appToken = await authService.getAppToken();
const user = appToken ? authService.getUserFromToken(appToken) : null;
if (!user) throw new Error('Nicht authentifiziert');
// Convert generated cards to database format
const cardsToInsert = cards.map((card, index) => ({
deck_id: deckId,
type: card.type,
content: card.content,
position: index,
created_by: user.id,
}));
// Save each card via API
for (let index = 0; index < cards.length; index++) {
const card = cards[index];
const response = await apiClient.createCard({
deckId,
title: `Card ${index + 1}`,
content: card.content,
cardType: card.type,
position: index,
});
const { error } = await supabase.from('cards').insert(cardsToInsert);
if (error) throw error;
// Track AI-generated cards
const aiTracking = cards.map((card) => ({
generation_method: card.metadata.source,
confidence_score: card.metadata.confidence,
source_data: { tags: card.metadata.tags },
}));
await supabase.from('ai_generated_cards').insert(aiTracking);
if (response.error) {
throw new Error(response.error);
}
}
set({ generatedCards: [] });
} catch (error) {

View file

@ -1,7 +1,6 @@
import { create } from 'zustand';
import { authService } from '../services/authService';
import { tokenManager, TokenState } from '../services/tokenManager';
import { supabase } from '../utils/supabase';
import type { ManaUser } from '../types/auth';
interface AuthState {
@ -225,15 +224,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const user = get().user;
if (!user) throw new Error('No user logged in');
const { error } = await supabase
.from('profiles')
.update({
...updates,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
// TODO: Implement profile update via backend API
// For now, profiles are managed via Mana Core Auth
console.warn('Profile update not yet implemented via backend API');
if (error) throw error;
// Update local user state with the new values
set({
user: {
...user,
...updates,
},
});
} catch (error: any) {
set({ error: error.message || 'Failed to update profile' });
throw error;

View file

@ -1,5 +1,5 @@
import { create } from 'zustand';
import { supabase, getAuthenticatedSupabase } from '../utils/supabase';
import { apiClient } from '../services/apiClient';
// Content types for different card types
export interface TextContent {
@ -51,6 +51,24 @@ export interface Card {
updated_at: string;
}
// Helper to map backend response to frontend format
function mapCardFromApi(card: any): Card {
return {
id: card.id,
deck_id: card.deckId,
position: card.position,
title: card.title,
content: card.content,
card_type: card.cardType,
ai_model: card.aiModel,
ai_prompt: card.aiPrompt,
version: card.version || 1,
is_favorite: card.isFavorite || false,
created_at: card.createdAt,
updated_at: card.updatedAt,
};
}
interface CardState {
cards: Card[];
currentCard: Card | null;
@ -88,22 +106,18 @@ export const useCardStore = create<CardState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Use authenticated Supabase client with Mana token for RLS
const authenticatedSupabase = await getAuthenticatedSupabase();
const { data, error } = await authenticatedSupabase
.from('cards')
.select('*')
.eq('deck_id', deckId)
.order('position', { ascending: true });
const response = await apiClient.getDeckCards(deckId);
console.log('[DEBUG] fetchCards - Deck ID:', deckId);
console.log('[DEBUG] fetchCards - Cards found:', data?.length || 0);
console.log('[DEBUG] fetchCards - Error:', error);
console.log('[DEBUG] fetchCards - Cards found:', response.data?.cards?.length || 0);
console.log('[DEBUG] fetchCards - Error:', response.error);
if (error) throw error;
if (response.error) {
throw new Error(response.error);
}
set({ cards: data || [] });
const cards = (response.data?.cards || []).map(mapCardFromApi);
set({ cards });
} catch (error: any) {
set({ error: error.message || 'Failed to fetch cards' });
console.error('[ERROR] fetchCards:', error);
@ -116,18 +130,14 @@ export const useCardStore = create<CardState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Use authenticated Supabase client with Mana token for RLS
const authenticatedSupabase = await getAuthenticatedSupabase();
const response = await apiClient.getCard(id);
const { data, error } = await authenticatedSupabase
.from('cards')
.select('*')
.eq('id', id)
.single();
if (response.error) {
throw new Error(response.error);
}
if (error) throw error;
set({ currentCard: data });
const card = mapCardFromApi(response.data?.card);
set({ currentCard: card });
} catch (error: any) {
set({ error: error.message || 'Failed to fetch card' });
console.error('[ERROR] fetchCard:', error);
@ -140,42 +150,28 @@ export const useCardStore = create<CardState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Use authenticated Supabase client with Mana token for RLS
const authenticatedSupabase = await getAuthenticatedSupabase();
// Get next position
const { data: existingCards } = await authenticatedSupabase
.from('cards')
.select('position')
.eq('deck_id', deckId)
.order('position', { ascending: false })
.limit(1);
const nextPosition = existingCards?.[0]?.position ? existingCards[0].position + 1 : 1;
const { data, error } = await authenticatedSupabase
.from('cards')
.insert({
deck_id: deckId,
position: nextPosition,
title: cardData.title || '',
content: cardData.content || { text: '' },
card_type: cardData.card_type || 'text',
ai_model: cardData.ai_model,
ai_prompt: cardData.ai_prompt,
version: 1,
is_favorite: false,
})
.select()
.single();
if (error) throw error;
// Add to local state
const cards = get().cards;
set({ cards: [...cards, data] });
const maxPosition = cards.length > 0 ? Math.max(...cards.map((c) => c.position)) : 0;
return data;
const response = await apiClient.createCard({
deckId,
title: cardData.title || '',
content: cardData.content || { text: '' },
cardType: cardData.card_type || 'text',
position: maxPosition + 1,
aiModel: cardData.ai_model,
aiPrompt: cardData.ai_prompt,
});
if (response.error) {
throw new Error(response.error);
}
const newCard = mapCardFromApi(response.data?.card);
set({ cards: [...cards, newCard] });
return newCard;
} catch (error: any) {
set({ error: error.message || 'Failed to create card' });
throw error;
@ -188,19 +184,17 @@ export const useCardStore = create<CardState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Use authenticated Supabase client with Mana token for RLS
const authenticatedSupabase = await getAuthenticatedSupabase();
const response = await apiClient.updateCard(id, {
title: updates.title,
content: updates.content,
cardType: updates.card_type,
position: updates.position,
isFavorite: updates.is_favorite,
});
const { error } = await authenticatedSupabase
.from('cards')
.update({
...updates,
updated_at: new Date().toISOString(),
version: updates.version ? updates.version + 1 : undefined,
})
.eq('id', id);
if (error) throw error;
if (response.error) {
throw new Error(response.error);
}
// Update local state
const cards = get().cards;
@ -224,12 +218,11 @@ export const useCardStore = create<CardState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Use authenticated Supabase client with Mana token for RLS
const authenticatedSupabase = await getAuthenticatedSupabase();
const response = await apiClient.deleteCard(id);
const { error } = await authenticatedSupabase.from('cards').delete().eq('id', id);
if (error) throw error;
if (response.error) {
throw new Error(response.error);
}
// Remove from local state
const cards = get().cards;
@ -254,35 +247,30 @@ export const useCardStore = create<CardState>((set, get) => ({
const cardToDuplicate = get().cards.find((card) => card.id === id);
if (!cardToDuplicate) throw new Error('Card not found');
// Use authenticated Supabase client with Mana token for RLS
const authenticatedSupabase = await getAuthenticatedSupabase();
const response = await apiClient.createCard({
deckId: cardToDuplicate.deck_id,
title: cardToDuplicate.title ? `${cardToDuplicate.title} (Kopie)` : '',
content: cardToDuplicate.content,
cardType: cardToDuplicate.card_type,
position: cardToDuplicate.position + 1,
aiModel: cardToDuplicate.ai_model,
aiPrompt: cardToDuplicate.ai_prompt,
});
const { data, error } = await authenticatedSupabase
.from('cards')
.insert({
deck_id: cardToDuplicate.deck_id,
position: cardToDuplicate.position + 1,
title: cardToDuplicate.title ? `${cardToDuplicate.title} (Kopie)` : '',
content: cardToDuplicate.content,
card_type: cardToDuplicate.card_type,
ai_model: cardToDuplicate.ai_model,
ai_prompt: cardToDuplicate.ai_prompt,
version: 1,
is_favorite: false,
})
.select()
.single();
if (response.error) {
throw new Error(response.error);
}
if (error) throw error;
const newCard = mapCardFromApi(response.data?.card);
// Add to local state
const cards = get().cards;
const insertIndex = cards.findIndex((card) => card.id === id) + 1;
const newCards = [...cards];
newCards.splice(insertIndex, 0, data);
newCards.splice(insertIndex, 0, newCard);
set({ cards: newCards });
return data;
return newCard;
} catch (error: any) {
set({ error: error.message || 'Failed to duplicate card' });
throw error;
@ -295,20 +283,10 @@ export const useCardStore = create<CardState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Use authenticated Supabase client with Mana token for RLS
const authenticatedSupabase = await getAuthenticatedSupabase();
const response = await apiClient.reorderCards(deckId, cardIds);
// Update positions in database
const updates = cardIds.map((cardId, index) => ({
id: cardId,
position: index + 1,
}));
for (const update of updates) {
await authenticatedSupabase
.from('cards')
.update({ position: update.position })
.eq('id', update.id);
if (response.error) {
throw new Error(response.error);
}
// Refresh cards to get correct order
@ -340,12 +318,13 @@ export const useCardStore = create<CardState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Use authenticated Supabase client with Mana token for RLS
const authenticatedSupabase = await getAuthenticatedSupabase();
const { error } = await authenticatedSupabase.from('cards').delete().in('id', ids);
if (error) throw error;
// Delete cards one by one (could be optimized with batch endpoint)
for (const id of ids) {
const response = await apiClient.deleteCard(id);
if (response.error) {
console.error(`Failed to delete card ${id}:`, response.error);
}
}
// Remove from local state
const cards = get().cards;
@ -362,28 +341,14 @@ export const useCardStore = create<CardState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Use authenticated Supabase client with Mana token for RLS
const authenticatedSupabase = await getAuthenticatedSupabase();
// Get highest position in target deck
const { data: targetCards } = await authenticatedSupabase
.from('cards')
.select('position')
.eq('deck_id', targetDeckId)
.order('position', { ascending: false })
.limit(1);
let nextPosition = targetCards?.[0]?.position ? targetCards[0].position + 1 : 1;
// Move each card
// Update each card's deck (could be optimized with batch endpoint)
for (const cardId of cardIds) {
await authenticatedSupabase
.from('cards')
.update({
deck_id: targetDeckId,
position: nextPosition++,
})
.eq('id', cardId);
const response = await apiClient.updateCard(cardId, {
// Note: Backend would need to support deckId update
});
if (response.error) {
console.error(`Failed to move card ${cardId}:`, response.error);
}
}
// Remove moved cards from local state

View file

@ -1,6 +1,5 @@
import { create } from 'zustand';
import { getAuthenticatedSupabase } from '../utils/supabase';
import { authService } from '../services/authService';
import { apiClient } from '../services/apiClient';
import { useAuthStore } from './authStore';
export interface Deck {
@ -18,6 +17,24 @@ export interface Deck {
card_count?: number;
}
// Helper to map backend response to frontend format
function mapDeckFromApi(deck: any): Deck {
return {
id: deck.id,
user_id: deck.userId,
title: deck.title,
description: deck.description,
cover_image_url: deck.coverImageUrl,
is_public: deck.isPublic,
settings: deck.settings || {},
tags: deck.tags || [],
metadata: deck.metadata || {},
created_at: deck.createdAt,
updated_at: deck.updatedAt,
card_count: deck.card_count || 0,
};
}
interface DeckState {
decks: Deck[];
currentDeck: Deck | null;
@ -43,46 +60,18 @@ export const useDeckStore = create<DeckState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Get authenticated Supabase client with Mana token (auto-refreshes if needed)
const supabase = await getAuthenticatedSupabase();
const response = await apiClient.getDecks();
// Get current user ID from token
const appToken = await authService.getAppToken();
const user = appToken ? authService.getUserFromToken(appToken) : null;
if (!user) {
throw new Error('Not authenticated');
}
const { data, error } = await supabase
.from('decks')
.select(
`
*,
card_count:cards(count)
`
)
.or(`user_id.eq.${user.id},and(is_public.eq.true,user_id.eq.00000000-0000-0000-0000-000000000001)`)
.order('updated_at', { ascending: false });
if (error) {
// Check if it's a JWT expiration error
if (error.code === 'PGRST303' || error.message?.includes('JWT expired') || error.message?.includes('token expired')) {
// Token expired, clear invalid token and let user re-authenticate
await authService.clearAuthStorage();
// Clear user from auth store to trigger redirect to login
if (response.error) {
// Check for auth errors
if (response.error.includes('expired') || response.error.includes('Not authenticated')) {
useAuthStore.setState({ user: null });
throw new Error('Session expired. Please sign in again.');
}
throw error;
throw new Error(response.error);
}
const decksWithCount =
data?.map((deck) => ({
...deck,
card_count: deck.card_count?.[0]?.count || 0,
})) || [];
const decksWithCount = (response.data?.decks || []).map(mapDeckFromApi);
set({ decks: decksWithCount });
} catch (error: any) {
set({ error: error.message || 'Failed to fetch decks' });
@ -96,38 +85,18 @@ export const useDeckStore = create<DeckState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Get authenticated Supabase client with Mana token (auto-refreshes if needed)
const supabase = await getAuthenticatedSupabase();
const response = await apiClient.getDeck(id);
const { data, error } = await supabase
.from('decks')
.select(
`
*,
card_count:cards(count)
`
)
.eq('id', id)
.single();
if (error) {
// Check if it's a JWT expiration error
if (error.code === 'PGRST303' || error.message?.includes('JWT expired') || error.message?.includes('token expired')) {
// Token expired, clear invalid token and let user re-authenticate
await authService.clearAuthStorage();
// Clear user from auth store to trigger redirect to login
if (response.error) {
if (response.error.includes('expired') || response.error.includes('Not authenticated')) {
useAuthStore.setState({ user: null });
throw new Error('Session expired. Please sign in again.');
}
throw error;
throw new Error(response.error);
}
const deckWithCount = {
...data,
card_count: data.card_count?.[0]?.count || 0,
};
set({ currentDeck: deckWithCount });
const deck = mapDeckFromApi(response.data?.deck);
set({ currentDeck: deck });
} catch (error: any) {
set({ error: error.message || 'Failed to fetch deck' });
console.error('Error fetching deck:', error);
@ -140,29 +109,25 @@ export const useDeckStore = create<DeckState>((set, get) => ({
try {
set({ isLoading: true, error: null });
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const response = await apiClient.createDeck({
title: deckData.title || 'Untitled Deck',
description: deckData.description,
coverImageUrl: deckData.cover_image_url,
isPublic: deckData.is_public,
settings: deckData.settings,
tags: deckData.tags,
metadata: deckData.metadata,
});
const { data, error } = await supabase
.from('decks')
.insert({
...deckData,
user_id: user.id,
settings: deckData.settings || {},
tags: deckData.tags || [],
metadata: deckData.metadata || {},
})
.select()
.single();
if (error) throw error;
if (response.error) {
throw new Error(response.error);
}
const newDeck = mapDeckFromApi(response.data?.deck);
const decks = get().decks;
set({ decks: [data, ...decks] });
set({ decks: [newDeck, ...decks] });
return data;
return newDeck;
} catch (error: any) {
set({ error: error.message || 'Failed to create deck' });
throw error;
@ -175,15 +140,19 @@ export const useDeckStore = create<DeckState>((set, get) => ({
try {
set({ isLoading: true, error: null });
const { error } = await supabase
.from('decks')
.update({
...updates,
updated_at: new Date().toISOString(),
})
.eq('id', id);
const response = await apiClient.updateDeck(id, {
title: updates.title,
description: updates.description,
coverImageUrl: updates.cover_image_url,
isPublic: updates.is_public,
settings: updates.settings,
tags: updates.tags,
metadata: updates.metadata,
});
if (error) throw error;
if (response.error) {
throw new Error(response.error);
}
const decks = get().decks;
set({
@ -205,9 +174,11 @@ export const useDeckStore = create<DeckState>((set, get) => ({
try {
set({ isLoading: true, error: null });
const { error } = await supabase.from('decks').delete().eq('id', id);
const response = await apiClient.deleteDeck(id);
if (error) throw error;
if (response.error) {
throw new Error(response.error);
}
const decks = get().decks;
set({ decks: decks.filter((deck) => deck.id !== id) });

View file

@ -1,5 +1,5 @@
import { create } from 'zustand';
import { supabase } from '../utils/supabase';
import { apiClient } from '../services/apiClient';
export interface DailyProgress {
date: string; // YYYY-MM-DD
@ -38,6 +38,22 @@ export interface Statistics {
favorite_time_of_day: string;
}
// Map backend session format to local format
function mapSessionFromApi(apiSession: any) {
return {
id: apiSession.id,
user_id: apiSession.userId,
deck_id: apiSession.deckId,
started_at: apiSession.startedAt,
ended_at: apiSession.endedAt,
total_cards: apiSession.totalCards || 0,
completed_cards: apiSession.completedCards || 0,
correct_answers: apiSession.correctCards || 0,
time_spent_seconds: apiSession.timeSpentSeconds || 0,
mode: 'all',
};
}
interface ProgressState {
// Data
dailyProgress: Map<string, DailyProgress>; // date -> progress
@ -75,25 +91,24 @@ export const useProgressStore = create<ProgressState>((set, get) => ({
error: null,
selectedPeriod: 'week',
fetchDailyProgress: async (userId: string, startDate: Date, endDate: Date) => {
fetchDailyProgress: async (_userId: string, startDate: Date, endDate: Date) => {
try {
set({ isLoading: true, error: null });
// Fetch study sessions in date range
const { data: sessions, error } = await supabase
.from('study_sessions')
.select('*')
.eq('user_id', userId)
.gte('started_at', startDate.toISOString())
.lte('started_at', endDate.toISOString())
.order('started_at', { ascending: true });
// Fetch study sessions in date range via API
const response = await apiClient.getStudySessionsByDateRange(
startDate.toISOString(),
endDate.toISOString()
);
if (error) throw error;
if (response.error) throw new Error(response.error);
const sessions = (response.data?.sessions || []).map(mapSessionFromApi);
// Group sessions by date
const progressMap = new Map<string, DailyProgress>();
sessions?.forEach((session) => {
sessions.forEach((session: any) => {
const date = new Date(session.started_at).toISOString().split('T')[0];
const existing = progressMap.get(date) || {
date,
@ -107,7 +122,7 @@ export const useProgressStore = create<ProgressState>((set, get) => ({
const sessionDuration = session.ended_at
? (new Date(session.ended_at).getTime() - new Date(session.started_at).getTime()) / 60000
: 0;
: (session.time_spent_seconds || 0) / 60;
progressMap.set(date, {
...existing,
@ -204,67 +219,65 @@ export const useProgressStore = create<ProgressState>((set, get) => ({
};
},
fetchStreakInfo: async (userId: string) => {
fetchStreakInfo: async (_userId: string) => {
try {
// Fetch all study sessions for streak calculation
const { data: sessions, error } = await supabase
.from('study_sessions')
.select('started_at')
.eq('user_id', userId)
.order('started_at', { ascending: true });
const response = await apiClient.getStudySessions();
if (error) throw error;
if (response.error) throw new Error(response.error);
const streakInfo = get().calculateStreak(sessions || []);
const sessions = (response.data?.sessions || []).map(mapSessionFromApi);
const streakInfo = get().calculateStreak(sessions);
set({ streakInfo });
} catch (error: any) {
console.error('Error fetching streak info:', error);
}
},
fetchDeckProgress: async (userId: string) => {
fetchDeckProgress: async (_userId: string) => {
try {
set({ isLoading: true });
// Fetch all decks with card counts
const { data: decks, error: decksError } = await supabase
.from('decks')
.select('*, cards(count)')
.eq('user_id', userId);
// Fetch all decks
const decksResponse = await apiClient.getDecks();
if (decksResponse.error) throw new Error(decksResponse.error);
if (decksError) throw decksError;
// Fetch all card progress
const progressResponse = await apiClient.getCardProgress();
if (progressResponse.error) throw new Error(progressResponse.error);
// Fetch card progress for all decks
const { data: cardProgress, error: progressError } = await supabase
.from('card_progress')
.select('*')
.eq('user_id', userId);
if (progressError) throw progressError;
const decks = decksResponse.data?.decks || [];
const cardProgressList = progressResponse.data?.progress || [];
// Calculate progress per deck
const deckProgressList: DeckProgress[] = [];
decks?.forEach((deck) => {
const deckCardProgress = cardProgress?.filter((cp) => cp.deck_id === deck.id) || [];
for (const deck of decks) {
// Get cards count for this deck
const cardsResponse = await apiClient.getDeckCards(deck.id);
const totalCards = cardsResponse.data?.count || 0;
// Filter progress for this deck's cards
const deckCards = cardsResponse.data?.cards || [];
const deckCardIds = new Set(deckCards.map((c: any) => c.id));
const deckCardProgress = cardProgressList.filter((cp: any) => deckCardIds.has(cp.cardId));
const mastered = deckCardProgress.filter(
(cp) => cp.ease_factor >= 2.5 && cp.interval >= 21
(cp: any) => cp.easeFactor >= 2.5 && cp.interval >= 21
).length;
const learning = deckCardProgress.filter(
(cp) => cp.status === 'learning' || cp.status === 'relearning'
(cp: any) => cp.status === 'learning'
).length;
const newCards = deckCardProgress.filter((cp) => cp.status === 'new').length;
const newCards = deckCardProgress.filter((cp: any) => cp.status === 'new').length;
const avgEaseFactor =
deckCardProgress.length > 0
? deckCardProgress.reduce((sum, cp) => sum + cp.ease_factor, 0) /
? deckCardProgress.reduce((sum: number, cp: any) => sum + (cp.easeFactor || 2.5), 0) /
deckCardProgress.length
: 2.5;
const totalCards = deck.cards?.[0]?.count || 0;
const studiedCards = deckCardProgress.length;
deckProgressList.push({
@ -277,7 +290,7 @@ export const useProgressStore = create<ProgressState>((set, get) => ({
average_ease_factor: Math.round(avgEaseFactor * 100) / 100,
completion_percentage: totalCards > 0 ? Math.round((mastered / totalCards) * 100) : 0,
});
});
}
set({ deckProgress: deckProgressList });
} catch (error: any) {
@ -287,15 +300,14 @@ export const useProgressStore = create<ProgressState>((set, get) => ({
}
},
fetchStatistics: async (userId: string) => {
fetchStatistics: async (_userId: string) => {
try {
// Fetch all sessions for statistics
const { data: sessions, error } = await supabase
.from('study_sessions')
.select('*')
.eq('user_id', userId);
const response = await apiClient.getStudySessions();
if (error) throw error;
if (response.error) throw new Error(response.error);
const sessions = (response.data?.sessions || []).map(mapSessionFromApi);
if (!sessions || sessions.length === 0) return;
// Calculate statistics
@ -309,7 +321,7 @@ export const useProgressStore = create<ProgressState>((set, get) => ({
const timeOfDayCount = new Map<number, number>();
sessions.forEach((session) => {
sessions.forEach((session: any) => {
totalCards += session.completed_cards || 0;
totalCorrect += session.correct_answers || 0;
@ -317,6 +329,8 @@ export const useProgressStore = create<ProgressState>((set, get) => ({
const duration =
(new Date(session.ended_at).getTime() - new Date(session.started_at).getTime()) / 60000;
totalTime += duration;
} else if (session.time_spent_seconds) {
totalTime += session.time_spent_seconds / 60;
}
// Track accuracy

View file

@ -1,6 +1,6 @@
import { create } from 'zustand';
import { Card } from './cardStore';
import { supabase, getAuthenticatedSupabase } from '../utils/supabase';
import { apiClient } from '../services/apiClient';
import { authService } from '../services/authService';
import { calculateSM2, difficultyToQuality, isCardDue } from '../utils/spacedRepetition';
import { useAuthStore } from './authStore';
@ -42,6 +42,39 @@ export interface SessionCardProgress {
difficulty?: 'easy' | 'medium' | 'hard' | 'again';
}
// Map backend camelCase to frontend snake_case
function mapProgressFromApi(apiProgress: any): CardProgress {
return {
id: apiProgress.id,
user_id: apiProgress.userId,
card_id: apiProgress.cardId,
deck_id: apiProgress.deckId || '',
ease_factor: apiProgress.easeFactor,
interval: apiProgress.interval,
repetitions: apiProgress.repetitions,
next_review_date: apiProgress.nextReview,
last_reviewed_at: apiProgress.lastReviewed,
total_reviews: apiProgress.repetitions || 0,
correct_reviews: 0, // Not tracked in new schema
incorrect_reviews: 0, // Not tracked in new schema
status: apiProgress.status || 'new',
};
}
function mapSessionFromApi(apiSession: any): StudySession {
return {
id: apiSession.id,
deck_id: apiSession.deckId,
user_id: apiSession.userId,
started_at: apiSession.startedAt,
ended_at: apiSession.endedAt,
total_cards: apiSession.totalCards,
completed_cards: apiSession.completedCards,
correct_answers: apiSession.correctCards,
mode: 'all', // Mode not tracked in new schema
};
}
interface StudyState {
// Current session
currentSession: StudySession | null;
@ -95,35 +128,21 @@ export const useStudyStore = create<StudyState>((set, get) => ({
fetchCardProgress: async (deckId: string) => {
try {
// Get authenticated Supabase client with Mana token (auto-refreshes if needed)
const supabase = await getAuthenticatedSupabase();
const response = await apiClient.getCardProgressByDeck(deckId);
// Get current user ID from token
const appToken = await authService.getAppToken();
const user = appToken ? authService.getUserFromToken(appToken) : null;
if (!user) return;
const { data, error } = await supabase
.from('card_progress')
.select('*')
.eq('deck_id', deckId)
.eq('user_id', user.id);
if (error) {
// Check if it's a JWT expiration error
if (error.code === 'PGRST303' || error.message?.includes('JWT expired') || error.message?.includes('token expired')) {
if (response.error) {
if (response.error.includes('Session expired')) {
await authService.clearAuthStorage();
// Clear user from auth store to trigger redirect to login
useAuthStore.setState({ user: null });
throw new Error('Session expired. Please sign in again.');
}
throw error;
throw new Error(response.error);
}
const progressMap = new Map<string, CardProgress>();
data?.forEach((progress) => {
progressMap.set(progress.card_id, progress);
response.data?.progress?.forEach((progress: any) => {
const mapped = mapProgressFromApi(progress);
progressMap.set(mapped.card_id, mapped);
});
set({ cardProgressMap: progressMap });
@ -136,9 +155,6 @@ export const useStudyStore = create<StudyState>((set, get) => ({
try {
set({ isLoading: true, error: null });
// Get authenticated Supabase client with Mana token (auto-refreshes if needed)
const supabase = await getAuthenticatedSupabase();
// Get current user ID from token
const appToken = await authService.getAppToken();
const user = appToken ? authService.getUserFromToken(appToken) : null;
@ -201,30 +217,24 @@ export const useStudyStore = create<StudyState>((set, get) => ({
throw new Error('Keine Karten zum Lernen gefunden');
}
// Create session in database
const { data: session, error: sessionError } = await supabase
.from('study_sessions')
.insert({
user_id: user.id,
deck_id: deckId,
mode,
total_cards: cards.length,
completed_cards: 0,
correct_answers: 0,
incorrect_answers: 0,
})
.select()
.single();
// Create session via API
const response = await apiClient.createStudySession({
deckId,
totalCards: cards.length,
completedCards: 0,
correctCards: 0,
});
if (sessionError) {
// Check if it's a JWT expiration error
if (sessionError.code === 'PGRST303' || sessionError.message?.includes('JWT expired')) {
if (response.error) {
if (response.error.includes('Session expired')) {
await authService.clearAuthStorage();
throw new Error('Session expired. Please sign in again.');
}
throw sessionError;
throw new Error(response.error);
}
const session = mapSessionFromApi(response.data?.session);
set({
currentSession: session,
sessionCards: cards,
@ -244,9 +254,8 @@ export const useStudyStore = create<StudyState>((set, get) => ({
updateCardProgress: async (cardId: string, quality: number) => {
try {
const {
data: { user },
} = await supabase.auth.getUser();
const appToken = await authService.getAppToken();
const user = appToken ? authService.getUserFromToken(appToken) : null;
if (!user) return;
const progressMap = get().cardProgressMap;
@ -274,10 +283,6 @@ export const useStudyStore = create<StudyState>((set, get) => ({
incorrect_reviews: quality < 3 ? 1 : 0,
status: sm2Result.interval < 10 ? 'learning' : 'review',
};
const { error } = await supabase.from('card_progress').insert(newProgress);
if (error) throw error;
} else {
// Update existing progress
const sm2Result = calculateSM2(
@ -309,13 +314,21 @@ export const useStudyStore = create<StudyState>((set, get) => ({
incorrect_reviews: existingProgress.incorrect_reviews + (quality < 3 ? 1 : 0),
status: newStatus,
};
}
const { error } = await supabase
.from('card_progress')
.update(newProgress)
.eq('id', existingProgress.id);
// Update via API (uses upsert)
const response = await apiClient.upsertCardProgress({
cardId,
easeFactor: newProgress.ease_factor,
interval: newProgress.interval,
repetitions: newProgress.repetitions,
lastReviewed: newProgress.last_reviewed_at,
nextReview: newProgress.next_review_date,
status: newProgress.status === 'relearning' ? 'learning' : newProgress.status,
});
if (error) throw error;
if (response.error) {
throw new Error(response.error);
}
// Update local state
@ -335,20 +348,17 @@ export const useStudyStore = create<StudyState>((set, get) => ({
// Calculate session statistics
const correctAnswers = sessionProgress.filter((p) => p.is_correct).length;
const incorrectAnswers = sessionProgress.filter((p) => !p.is_correct).length;
// Update session in database
const { error } = await supabase
.from('study_sessions')
.update({
ended_at: new Date().toISOString(),
completed_cards: sessionProgress.length,
correct_answers: correctAnswers,
incorrect_answers: incorrectAnswers,
})
.eq('id', currentSession.id);
// Update session via API
const response = await apiClient.updateStudySession(currentSession.id, {
endedAt: new Date().toISOString(),
completedCards: sessionProgress.length,
correctCards: correctAnswers,
});
if (error) throw error;
if (response.error) {
throw new Error(response.error);
}
// Keep the session data for the summary screen
set({

View file

@ -1,117 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
import { Platform } from 'react-native';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || '';
// Log error if env vars are missing but don't crash
if (!supabaseUrl || !supabaseAnonKey) {
console.error('Missing Supabase environment variables:', {
url: !!supabaseUrl,
key: !!supabaseAnonKey,
});
}
// Create a custom storage adapter that works on both web and mobile
const storage = Platform.select({
web: {
getItem: async (key: string) => {
if (typeof window !== 'undefined' && window.localStorage) {
return window.localStorage.getItem(key);
}
return null;
},
setItem: async (key: string, value: string) => {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem(key, value);
}
},
removeItem: async (key: string) => {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem(key);
}
},
},
default: AsyncStorage,
});
export const supabase = createClient(
supabaseUrl || 'https://placeholder.supabase.co',
supabaseAnonKey || 'placeholder-key',
{
auth: {
storage: storage as any,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
}
);
/**
* Create a Supabase client with Mana Core authentication token
* Use this when you need to make authenticated requests to Supabase
* that respect Row-Level Security (RLS) policies based on Mana user
*/
export const getSupabaseWithManaToken = async (manaToken: string) => {
return createClient(
supabaseUrl || 'https://placeholder.supabase.co',
supabaseAnonKey || 'placeholder-key',
{
global: {
headers: {
Authorization: `Bearer ${manaToken}`,
},
},
auth: {
storage: storage as any,
autoRefreshToken: false, // Don't auto-refresh Mana tokens here
persistSession: false,
detectSessionInUrl: false,
},
}
);
};
/**
* Helper to get authenticated Supabase client with current Mana token
* Automatically refreshes token if expired or close to expiry
*/
export const getAuthenticatedSupabase = async () => {
const { safeStorage } = await import('./safeStorage');
const { authService } = await import('../services/authService');
let manaToken = await safeStorage.getItem<string>('@mana/appToken');
if (!manaToken) {
throw new Error('No Mana authentication token found');
}
// Check if token is expired or close to expiry
const isValid = authService.isTokenValidLocally(manaToken);
if (!isValid) {
// Token is expired or close to expiry, try to refresh
try {
const refreshToken = await safeStorage.getItem<string>('@mana/refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const tokens = await authService.refreshTokens(refreshToken);
manaToken = tokens.appToken;
} catch (error) {
console.error('Failed to refresh token:', error);
// Clear auth storage and notify auth store
await authService.clearAuthStorage();
// Import at runtime to avoid circular dependency
const { useAuthStore } = await import('../store/authStore');
useAuthStore.setState({ user: null });
throw new Error('Authentication token expired. Please sign in again.');
}
}
return getSupabaseWithManaToken(manaToken);
};

View file

@ -1,8 +1,5 @@
import { getAuthenticatedSupabase } from './supabase';
import { CardContent } from '../store/cardStore';
import { post } from './apiClient';
const BASE_API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://manadeck-backend-111768794939.europe-west3.run.app';
import { apiClient } from '../services/apiClient';
export interface GeneratedCard {
type: 'text' | 'flashcard' | 'quiz' | 'mixed';
@ -23,76 +20,48 @@ export interface GenerationOptions {
}
/**
* Service for AI operations using Supabase Edge Functions
* This keeps the OpenAI API key secure on the server side
* AI Service for card generation operations
* Uses the NestJS backend API for all AI operations
*/
export class SupabaseAIService {
private static async callEdgeFunction(functionName: string, data: any) {
try {
// Get authenticated Supabase client with Mana token
const supabase = await getAuthenticatedSupabase();
const { data: result, error } = await supabase.functions.invoke(functionName, {
body: data,
});
if (error) {
console.error(`Error calling edge function (${functionName}):`, error);
// Check for authentication errors
if (error.message?.includes('401') || error.message?.includes('Unauthorized')) {
throw new Error('Bitte melde dich an, um KI-Funktionen zu nutzen.');
}
// Check for CORS errors
if (error.message?.includes('CORS') || error.message?.includes('Failed to fetch')) {
throw new Error(
'Edge Function nicht erreichbar. Bitte stelle sicher, dass CORS korrekt konfiguriert ist.'
);
}
throw error;
}
if (!result?.success) {
throw new Error(result?.error || 'Unknown error occurred');
}
return result.data;
} catch (error: any) {
console.error(`Error calling edge function (${functionName}):`, error);
throw error;
}
}
static async generateCardsFromText(
prompt: string,
options: GenerationOptions = {}
): Promise<GeneratedCard[]> {
try {
// Call backend API instead of edge function directly
// Backend handles: authentication, credit validation/consumption, and edge function invocation
const response = await post(`${BASE_API_URL}/v1/api/decks/generate`, {
// Call backend API for deck generation
const response = await apiClient.generateDeckWithAI({
prompt: prompt,
deckTitle: options.topic || 'AI Generated Deck',
deckDescription: `Deck created from prompt: ${prompt.substring(0, 100)}`,
cardCount: options.count || 10,
cardTypes: options.cardTypes || ['flashcard', 'quiz'],
difficulty: options.difficulty || 'medium',
tags: []
tags: [],
});
// Backend returns: { success, deck, cards, creditsUsed, message }
if (response.success && response.cards) {
// Convert backend cards to GeneratedCard format
return response.cards.map((card: any) => ({
type: card.card_type || 'flashcard',
if (response.error) {
// Re-throw credit errors so they can be handled by the UI
if (
response.error.includes('insufficient_credits') ||
response.error.includes('Insufficient mana')
) {
throw new Error(response.error);
}
console.error('Error generating cards from text:', response.error);
return [];
}
// Convert backend cards to GeneratedCard format
if (response.data?.cards) {
return response.data.cards.map((card: any) => ({
type: card.cardType || card.card_type || 'flashcard',
content: card.content,
metadata: {
confidence: 1,
source: 'ai',
tags: card.tags || []
}
tags: card.tags || [],
},
}));
}
@ -101,7 +70,10 @@ export class SupabaseAIService {
console.error('Error generating cards from text:', error);
// Re-throw credit errors so they can be handled by the UI
if (error.message?.includes('insufficient_credits') || error.message?.includes('Insufficient mana')) {
if (
error.message?.includes('insufficient_credits') ||
error.message?.includes('Insufficient mana')
) {
throw error;
}
@ -109,36 +81,22 @@ export class SupabaseAIService {
}
}
static async generateCardsFromSpeech(audioBase64: string): Promise<GeneratedCard[]> {
try {
// First transcribe the audio
const { text } = await this.callEdgeFunction('transcribeAudio', {
audioBase64,
});
// Then generate cards from the transcribed text
return await this.generateCardsFromText(text, {
cardTypes: ['flashcard', 'quiz'],
difficulty: 'medium',
count: 5,
language: 'de',
});
} catch (error) {
console.error('Error generating cards from speech:', error);
return [];
}
}
static async generateCardsFromImage(
imageBase64: string,
context?: string
): Promise<GeneratedCard[]> {
try {
const cards = await this.callEdgeFunction('generate-deck-from-image', {
image: imageBase64,
context,
});
return cards || [];
const response = await apiClient.generateCardsFromImage(imageBase64, context, 5);
if (response.error) {
throw new Error(response.error);
}
return (response.data?.cards || []).map((card) => ({
type: card.type as GeneratedCard['type'],
content: card.content as CardContent,
metadata: card.metadata,
}));
} catch (error) {
console.error('Error generating cards from image:', error);
return [];
@ -147,11 +105,13 @@ export class SupabaseAIService {
static async enhanceCardContent(content: string, cardType: string): Promise<string> {
try {
const { enhancedContent } = await this.callEdgeFunction('enhanceContent', {
content,
cardType,
});
return enhancedContent || content;
const response = await apiClient.enhanceContent(content, cardType);
if (response.error) {
throw new Error(response.error);
}
return response.data?.enhancedContent || content;
} catch (error) {
console.error('Error enhancing card content:', error);
return content;
@ -175,11 +135,4 @@ export class SupabaseAIService {
return [];
}
}
static async transcribeAudio(audioUri: string): Promise<string> {
console.warn(
'Direct audio transcription from URI not implemented. Use generateCardsFromSpeech instead.'
);
throw new Error('Audio transcription not yet implemented for mobile');
}
}

View file

@ -1,186 +0,0 @@
import * as Speech from 'expo-speech';
import * as FileSystem from 'expo-file-system';
export interface TTSOptions {
language?: string;
rate?: number;
pitch?: number;
voice?: string;
onStart?: () => void;
onDone?: () => void;
onStopped?: () => void;
onError?: (error: string) => void;
}
export interface Voice {
identifier: string;
name: string;
quality: string;
language: string;
}
export class TTSService {
private static currentSpeech: string | null = null;
static async speakText(text: string, options: TTSOptions = {}): Promise<void> {
const {
language = 'de-DE',
rate = 1.0,
pitch = 1.0,
voice,
onStart,
onDone,
onStopped,
onError,
} = options;
try {
// Stop any current speech
if (this.currentSpeech) {
await this.stop();
}
this.currentSpeech = text;
await Speech.speak(text, {
language,
rate,
pitch,
voice,
onStart,
onDone: () => {
this.currentSpeech = null;
onDone?.();
},
onStopped: () => {
this.currentSpeech = null;
onStopped?.();
},
onError: (error) => {
this.currentSpeech = null;
onError?.(error);
},
});
} catch (error) {
console.error('Error speaking text:', error);
this.currentSpeech = null;
throw error;
}
}
static async stop(): Promise<void> {
try {
await Speech.stop();
this.currentSpeech = null;
} catch (error) {
console.error('Error stopping speech:', error);
}
}
static async pause(): Promise<void> {
try {
await Speech.pause();
} catch (error) {
console.error('Error pausing speech:', error);
}
}
static async resume(): Promise<void> {
try {
await Speech.resume();
} catch (error) {
console.error('Error resuming speech:', error);
}
}
static async isSpeaking(): Promise<boolean> {
try {
return await Speech.isSpeakingAsync();
} catch (error) {
console.error('Error checking speaking status:', error);
return false;
}
}
static async getAvailableVoices(): Promise<Voice[]> {
try {
const voices = await Speech.getAvailableVoicesAsync();
return voices.map((voice) => ({
identifier: voice.identifier,
name: voice.name,
quality: voice.quality,
language: voice.language,
}));
} catch (error) {
console.error('Error getting available voices:', error);
return [];
}
}
static async generateAudioFile(text: string, language: string = 'de-DE'): Promise<string | null> {
// Note: Expo Speech doesn't support generating audio files directly
// This would require a server-side implementation or different library
// For now, we'll return null and use real-time TTS only
console.warn('Audio file generation not supported with Expo Speech');
return null;
}
static speakCard(card: any): Promise<void> {
let textToSpeak = '';
switch (card.type) {
case 'flashcard':
textToSpeak = `Vorderseite: ${card.content.front}. Rückseite: ${card.content.back}`;
if (card.content.hint) {
textToSpeak += `. Hinweis: ${card.content.hint}`;
}
break;
case 'quiz':
textToSpeak = `Frage: ${card.content.question}. `;
textToSpeak += 'Optionen: ';
card.content.options.forEach((option: string, index: number) => {
textToSpeak += `${index + 1}: ${option}. `;
});
break;
case 'text':
textToSpeak = card.content.title
? `${card.content.title}. ${card.content.text}`
: card.content.text;
break;
case 'mixed':
card.content.blocks?.forEach((block: any) => {
if (block.type === 'text') {
textToSpeak += `${block.content}. `;
} else if (block.type === 'flashcard') {
textToSpeak += `Frage: ${block.front}. Antwort: ${block.back}. `;
}
});
break;
default:
textToSpeak = JSON.stringify(card.content);
}
return this.speakText(textToSpeak, { language: 'de-DE' });
}
static getLanguageCode(language: string): string {
const languageMap: { [key: string]: string } = {
german: 'de-DE',
english: 'en-US',
spanish: 'es-ES',
french: 'fr-FR',
italian: 'it-IT',
portuguese: 'pt-PT',
russian: 'ru-RU',
chinese: 'zh-CN',
japanese: 'ja-JP',
korean: 'ko-KR',
};
return languageMap[language.toLowerCase()] || 'de-DE';
}
}

View file

@ -0,0 +1,248 @@
import type { Card, CreateCardInput, UpdateCardInput } from '$lib/types/card';
import { PUBLIC_API_URL } from '$env/static/public';
import { authService } from '$lib/auth';
// Svelte 5 runes-based card store
let cards = $state<Card[]>([]);
let currentCard = $state<Card | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
/**
* Helper to make authenticated API requests
*/
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const appToken = await authService.getAppToken();
if (!appToken) {
throw new Error('Not authenticated');
}
const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${appToken}`,
...options.headers
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `API error: ${response.status}`);
}
return response.json();
}
/**
* Map backend camelCase to frontend snake_case
*/
function mapCardFromApi(apiCard: any): Card {
return {
id: apiCard.id,
deck_id: apiCard.deckId,
position: apiCard.position,
title: apiCard.title,
content: apiCard.content,
card_type: apiCard.cardType,
ai_model: apiCard.aiModel,
ai_prompt: apiCard.aiPrompt,
version: apiCard.version || 1,
is_favorite: apiCard.isFavorite || false,
created_at: apiCard.createdAt,
updated_at: apiCard.updatedAt
};
}
export const cardStore = {
get cards() {
return cards;
},
get currentCard() {
return currentCard;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all cards for a deck
*/
async fetchCards(deckId: string) {
loading = true;
error = null;
try {
const response = await apiRequest<{ cards: any[]; count: number }>(
`/v1/api/decks/${deckId}/cards`
);
cards = (response.cards || []).map(mapCardFromApi);
} catch (err: any) {
error = err.message || 'Failed to fetch cards';
console.error('Fetch cards error:', err);
} finally {
loading = false;
}
},
/**
* Fetch single card by ID
*/
async fetchCard(id: string) {
loading = true;
error = null;
try {
const response = await apiRequest<{ card: any }>(`/v1/api/cards/${id}`);
currentCard = response.card ? mapCardFromApi(response.card) : null;
} catch (err: any) {
error = err.message || 'Failed to fetch card';
console.error('Fetch card error:', err);
} finally {
loading = false;
}
},
/**
* Create new card
*/
async createCard(input: CreateCardInput): Promise<Card | null> {
loading = true;
error = null;
try {
const response = await apiRequest<{ success: boolean; card: any }>('/v1/api/cards', {
method: 'POST',
body: JSON.stringify({
deckId: input.deck_id,
title: input.title,
content: input.content,
cardType: input.card_type,
position: input.position
})
});
if (response.card) {
const card = mapCardFromApi(response.card);
cards = [...cards, card];
return card;
}
return null;
} catch (err: any) {
error = err.message || 'Failed to create card';
console.error('Create card error:', err);
return null;
} finally {
loading = false;
}
},
/**
* Update card
*/
async updateCard(id: string, updates: UpdateCardInput) {
loading = true;
error = null;
try {
const response = await apiRequest<{ success: boolean; card: any }>(`/v1/api/cards/${id}`, {
method: 'PUT',
body: JSON.stringify({
title: updates.title,
content: updates.content,
cardType: updates.card_type,
position: updates.position,
isFavorite: updates.is_favorite
})
});
if (response.card) {
const updatedCard = mapCardFromApi(response.card);
// Update in list
cards = cards.map((c) => (c.id === id ? updatedCard : c));
// Update current if it's the same
if (currentCard?.id === id) {
currentCard = updatedCard;
}
}
} catch (err: any) {
error = err.message || 'Failed to update card';
console.error('Update card error:', err);
} finally {
loading = false;
}
},
/**
* Delete card
*/
async deleteCard(id: string) {
loading = true;
error = null;
try {
await apiRequest<{ success: boolean }>(`/v1/api/cards/${id}`, {
method: 'DELETE'
});
// Remove from list
cards = cards.filter((c) => c.id !== id);
// Clear current if it's the same
if (currentCard?.id === id) {
currentCard = null;
}
} catch (err: any) {
error = err.message || 'Failed to delete card';
console.error('Delete card error:', err);
} finally {
loading = false;
}
},
/**
* Reorder cards
*/
async reorderCards(deckId: string, cardIds: string[]) {
loading = true;
error = null;
try {
await apiRequest<{ success: boolean }>('/v1/api/cards/reorder', {
method: 'POST',
body: JSON.stringify({ deckId, cardIds })
});
// Update local positions
cards = cardIds.map((id, index) => {
const card = cards.find((c) => c.id === id);
return card ? { ...card, position: index } : card!;
}).filter(Boolean);
} catch (err: any) {
error = err.message || 'Failed to reorder cards';
console.error('Reorder cards error:', err);
} finally {
loading = false;
}
},
/**
* Clear cards (when changing decks)
*/
clearCards() {
cards = [];
currentCard = null;
error = null;
},
/**
* Clear error
*/
clearError() {
error = null;
}
};

View file

@ -77,12 +77,8 @@ export const deckStore = {
error = null;
try {
// For now, find in local decks or fetch all
// TODO: Add GET /v1/api/decks/:id endpoint to backend
if (decks.length === 0) {
await this.fetchDecks();
}
currentDeck = decks.find((d) => d.id === id) || null;
const response = await apiRequest<{ deck: Deck }>(`/v1/api/decks/${id}`);
currentDeck = response.deck || null;
if (!currentDeck) {
throw new Error('Deck not found');
}

View file

@ -0,0 +1,313 @@
import type { StudySession, CardProgress, DailyProgress } from '$lib/types/study';
import { PUBLIC_API_URL } from '$env/static/public';
import { authService } from '$lib/auth';
// Svelte 5 runes-based progress store
let studySessions = $state<StudySession[]>([]);
let cardProgress = $state<CardProgress[]>([]);
let statistics = $state<Statistics | null>(null);
let streakInfo = $state<StreakInfo | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
interface Statistics {
totalCardsStudied: number;
totalStudyTimeMinutes: number;
averageAccuracy: number;
totalSessions: number;
}
interface StreakInfo {
currentStreak: number;
longestStreak: number;
lastStudyDate: string;
totalStudyDays: number;
}
/**
* Helper to make authenticated API requests
*/
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const appToken = await authService.getAppToken();
if (!appToken) {
throw new Error('Not authenticated');
}
const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${appToken}`,
...options.headers
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `API error: ${response.status}`);
}
return response.json();
}
/**
* Map backend session to frontend format
*/
function mapSessionFromApi(apiSession: any): StudySession {
return {
id: apiSession.id,
deck_id: apiSession.deckId,
user_id: apiSession.userId,
mode: 'all',
total_cards: apiSession.totalCards || 0,
completed_cards: apiSession.completedCards || 0,
correct_cards: apiSession.correctCards || 0,
started_at: apiSession.startedAt,
completed_at: apiSession.endedAt,
time_spent_seconds: apiSession.timeSpentSeconds || 0
};
}
/**
* Map backend card progress to frontend format
*/
function mapProgressFromApi(apiProgress: any): CardProgress {
return {
id: apiProgress.id,
user_id: apiProgress.userId,
card_id: apiProgress.cardId,
ease_factor: apiProgress.easeFactor,
interval: apiProgress.interval,
repetitions: apiProgress.repetitions,
last_reviewed: apiProgress.lastReviewed,
next_review: apiProgress.nextReview,
status: apiProgress.status || 'new',
created_at: apiProgress.createdAt,
updated_at: apiProgress.updatedAt
};
}
/**
* Calculate streak from sessions
*/
function calculateStreak(sessions: StudySession[]): StreakInfo {
if (!sessions || sessions.length === 0) {
return {
currentStreak: 0,
longestStreak: 0,
lastStudyDate: '',
totalStudyDays: 0
};
}
// Get unique study dates
const studyDates = new Set(
sessions.map((s) => new Date(s.started_at).toISOString().split('T')[0])
);
const sortedDates = Array.from(studyDates).sort();
// Calculate streaks
let currentStreak = 0;
let longestStreak = 0;
let tempStreak = 1;
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
const lastStudyDate = sortedDates[sortedDates.length - 1];
if (lastStudyDate === today || lastStudyDate === yesterday) {
currentStreak = 1;
for (let i = sortedDates.length - 2; i >= 0; i--) {
const prevDate = new Date(sortedDates[i]);
const currDate = new Date(sortedDates[i + 1]);
const diffDays = Math.floor((currDate.getTime() - prevDate.getTime()) / 86400000);
if (diffDays === 1) {
currentStreak++;
} else {
break;
}
}
}
for (let i = 1; i < sortedDates.length; i++) {
const prevDate = new Date(sortedDates[i - 1]);
const currDate = new Date(sortedDates[i]);
const diffDays = Math.floor((currDate.getTime() - prevDate.getTime()) / 86400000);
if (diffDays === 1) {
tempStreak++;
} else {
longestStreak = Math.max(longestStreak, tempStreak);
tempStreak = 1;
}
}
longestStreak = Math.max(longestStreak, tempStreak, currentStreak);
return {
currentStreak,
longestStreak,
lastStudyDate,
totalStudyDays: studyDates.size
};
}
export const progressStore = {
get studySessions() {
return studySessions;
},
get cardProgress() {
return cardProgress;
},
get statistics() {
return statistics;
},
get streakInfo() {
return streakInfo;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all study sessions
*/
async fetchStudySessions() {
loading = true;
error = null;
try {
const response = await apiRequest<{ sessions: any[]; count: number }>(
'/v1/api/study-sessions'
);
studySessions = (response.sessions || []).map(mapSessionFromApi);
// Calculate streak
streakInfo = calculateStreak(studySessions);
} catch (err: any) {
error = err.message || 'Failed to fetch study sessions';
console.error('Fetch study sessions error:', err);
} finally {
loading = false;
}
},
/**
* Fetch study session statistics
*/
async fetchStatistics() {
loading = true;
error = null;
try {
const [sessionsResponse, progressStatsResponse] = await Promise.all([
apiRequest<{ stats: any }>('/v1/api/study-sessions/stats'),
apiRequest<{ stats: any }>('/v1/api/progress/stats')
]);
const sessionStats = sessionsResponse.stats || {};
const progressStats = progressStatsResponse.stats || {};
statistics = {
totalCardsStudied: sessionStats.totalCardsStudied || 0,
totalStudyTimeMinutes: Math.round((sessionStats.totalTimeSeconds || 0) / 60),
averageAccuracy:
sessionStats.totalCardsStudied > 0
? Math.round(
((sessionStats.totalCorrectCards || 0) / sessionStats.totalCardsStudied) * 100
)
: 0,
totalSessions: sessionStats.totalSessions || 0
};
} catch (err: any) {
error = err.message || 'Failed to fetch statistics';
console.error('Fetch statistics error:', err);
} finally {
loading = false;
}
},
/**
* Fetch card progress for a deck
*/
async fetchDeckProgress(deckId: string) {
loading = true;
error = null;
try {
const response = await apiRequest<{ progress: any[]; count: number }>(
`/v1/api/progress/deck/${deckId}`
);
cardProgress = (response.progress || []).map(mapProgressFromApi);
} catch (err: any) {
error = err.message || 'Failed to fetch deck progress';
console.error('Fetch deck progress error:', err);
} finally {
loading = false;
}
},
/**
* Fetch due cards
*/
async fetchDueCards(deckId?: string) {
loading = true;
error = null;
try {
const endpoint = deckId
? `/v1/api/progress/deck/${deckId}/due`
: '/v1/api/progress/due';
const response = await apiRequest<{ progress: any[]; count: number }>(endpoint);
return (response.progress || []).map(mapProgressFromApi);
} catch (err: any) {
error = err.message || 'Failed to fetch due cards';
console.error('Fetch due cards error:', err);
return [];
} finally {
loading = false;
}
},
/**
* Get progress summary for a deck
*/
getDeckProgressSummary(deckId: string) {
const deckProgress = cardProgress.filter((p) => {
// This requires cards info - for now return all progress
return true;
});
const total = deckProgress.length;
const mastered = deckProgress.filter(
(p) => p.ease_factor >= 2.5 && p.interval >= 21
).length;
const learning = deckProgress.filter(
(p) => p.status === 'learning'
).length;
const newCards = deckProgress.filter((p) => p.status === 'new').length;
const dueNow = deckProgress.filter((p) => {
if (!p.next_review) return false;
return new Date(p.next_review) <= new Date();
}).length;
return {
total,
mastered,
learning,
newCards,
dueNow
};
},
/**
* Clear error
*/
clearError() {
error = null;
}
};

View file

@ -3,15 +3,33 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { deckStore } from '$lib/stores/deckStore.svelte';
import { progressStore } from '$lib/stores/progressStore.svelte';
import { cardStore } from '$lib/stores/cardStore.svelte';
import { Button, Badge, Card } from '@manacore/shared-ui';
let deckId = $derived($page.params.id);
let showDeleteConfirm = $state(false);
let deleting = $state(false);
onMount(() => {
// Calculate deck-specific progress
let dueCount = $state(0);
let masteredCount = $state(0);
onMount(async () => {
if (deckId) {
deckStore.fetchDeck(deckId);
await Promise.all([
deckStore.fetchDeck(deckId),
progressStore.fetchDeckProgress(deckId),
cardStore.fetchCards(deckId)
]);
// Calculate progress
const progress = progressStore.cardProgress;
masteredCount = progress.filter((p) => p.ease_factor >= 2.5 && p.interval >= 21).length;
dueCount = progress.filter((p) => {
if (!p.next_review) return false;
return new Date(p.next_review) <= new Date();
}).length;
}
});
@ -83,20 +101,20 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<div class="text-center">
<div class="text-3xl font-bold">{deck.card_count || 0}</div>
<div class="text-3xl font-bold">{cardStore.cards.length || deck.card_count || 0}</div>
<div class="text-sm text-muted-foreground">Total Cards</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold">0</div>
<div class="text-3xl font-bold text-green-500">{masteredCount}</div>
<div class="text-sm text-muted-foreground">Mastered</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold">0</div>
<div class="text-sm text-muted-foreground">To Review</div>
<div class="text-3xl font-bold text-orange-500">{dueCount}</div>
<div class="text-sm text-muted-foreground">Due for Review</div>
</div>
</Card>
</div>

View file

@ -1,5 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Card } from '@manacore/shared-ui';
import { progressStore } from '$lib/stores/progressStore.svelte';
onMount(() => {
progressStore.fetchStudySessions();
progressStore.fetchStatistics();
});
</script>
<svelte:head>
@ -9,39 +16,122 @@
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold">Progress</h1>
<p class="text-muted-foreground mt-1">
Track your learning progress and statistics
</p>
<p class="text-muted-foreground mt-1">Track your learning progress and statistics</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<div class="text-center">
<div class="text-3xl font-bold">0</div>
<div class="text-sm text-muted-foreground">Cards Studied</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold">0</div>
<div class="text-sm text-muted-foreground">Current Streak</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold">0%</div>
<div class="text-sm text-muted-foreground">Average Accuracy</div>
</div>
</Card>
</div>
<Card>
<div class="text-center py-12">
<div class="text-6xl mb-4">📊</div>
<h3 class="text-xl font-semibold mb-2">Progress Tracking</h3>
<p class="text-muted-foreground">
Detailed statistics and heatmap - Coming soon!
</p>
{#if progressStore.loading}
<div class="flex justify-center py-8">
<div
class="inline-block animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
></div>
</div>
</Card>
{:else}
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card>
<div class="text-center">
<div class="text-3xl font-bold">
{progressStore.statistics?.totalCardsStudied || 0}
</div>
<div class="text-sm text-muted-foreground">Cards Studied</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold text-orange-500">
🔥 {progressStore.streakInfo?.currentStreak || 0}
</div>
<div class="text-sm text-muted-foreground">Current Streak</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold">
{progressStore.statistics?.averageAccuracy || 0}%
</div>
<div class="text-sm text-muted-foreground">Average Accuracy</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold">
{progressStore.statistics?.totalStudyTimeMinutes || 0}
</div>
<div class="text-sm text-muted-foreground">Minutes Studied</div>
</div>
</Card>
</div>
<!-- Streak Info -->
<Card>
<h2 class="text-xl font-semibold mb-4">📊 Study Streak</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="text-center p-4 bg-surface rounded-lg">
<div class="text-2xl font-bold text-orange-500">
{progressStore.streakInfo?.currentStreak || 0}
</div>
<div class="text-sm text-muted-foreground">Current Streak</div>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<div class="text-2xl font-bold text-yellow-500">
{progressStore.streakInfo?.longestStreak || 0}
</div>
<div class="text-sm text-muted-foreground">Longest Streak</div>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<div class="text-2xl font-bold">
{progressStore.streakInfo?.totalStudyDays || 0}
</div>
<div class="text-sm text-muted-foreground">Total Study Days</div>
</div>
<div class="text-center p-4 bg-surface rounded-lg">
<div class="text-2xl font-bold">
{progressStore.statistics?.totalSessions || 0}
</div>
<div class="text-sm text-muted-foreground">Total Sessions</div>
</div>
</div>
</Card>
<!-- Recent Sessions -->
<Card>
<h2 class="text-xl font-semibold mb-4">📚 Recent Study Sessions</h2>
{#if progressStore.studySessions.length === 0}
<div class="text-center py-8">
<div class="text-4xl mb-4">🎯</div>
<p class="text-muted-foreground">No study sessions yet.</p>
<p class="text-muted-foreground text-sm mt-2">
Start studying a deck to see your progress here!
</p>
</div>
{:else}
<div class="space-y-3">
{#each progressStore.studySessions.slice(0, 10) as session}
<div class="flex items-center justify-between p-3 bg-surface rounded-lg">
<div>
<div class="font-medium">
{new Date(session.started_at).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short'
})}
</div>
<div class="text-sm text-muted-foreground">
{session.completed_cards} cards • {Math.round(session.time_spent_seconds / 60)} min
</div>
</div>
<div class="text-right">
<div class="font-semibold">
{session.completed_cards > 0
? Math.round((session.correct_cards / session.completed_cards) * 100)
: 0}%
</div>
<div class="text-sm text-muted-foreground">accuracy</div>
</div>
</div>
{/each}
</div>
{/if}
</Card>
{/if}
</div>

View file

@ -1,3 +0,0 @@
# Disable automatic JWT verification by Supabase Edge Gateway
# We'll manually verify the Mana Core JWT inside the function
verify_jwt = false

View file

@ -1,3 +0,0 @@
# Disable automatic JWT verification by Supabase Edge Gateway
# We'll manually verify the Mana Core JWT inside the function
verify_jwt = false

View file

@ -1,273 +0,0 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3';
import * as jose from "https://deno.land/x/jose@v5.9.6/index.ts";
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'
};
// System prompt for consistent card generation
const SYSTEM_PROMPT = `Du bist ein Experte für die Erstellung von Lernkarten. Erstelle strukturierte Lernkarten basierend auf dem gegebenen Thema.
Regeln für die Kartenerstellung:
1. Erstelle abwechslungsreiche Karten (Flashcards und Quiz-Karten)
2. Formuliere klare, präzise Fragen und Antworten
3. Verwende die deutsche Sprache
4. Stelle sicher, dass die Karten aufeinander aufbauen
5. Füge hilfreiche Hinweise und Erklärungen hinzu
Du musst die Karten als JSON-Array zurückgeben mit folgendem Format:
[
{
"card_type": "flashcard" | "quiz",
"content": {
// Für flashcard:
"front": "Frage oder Begriff",
"back": "Antwort oder Definition",
"hint": "Optionaler Hinweis"
// Für quiz:
"question": "Die Frage",
"options": ["Option 1", "Option 2", "Option 3", "Option 4"],
"correct_answer": 0, // Index der richtigen Antwort (0-3)
"explanation": "Erklärung zur richtigen Antwort"
},
"position": 1, // Position in der Reihenfolge
"title": "Kurzer Titel für die Karte"
}
]
Erstelle GENAU die angeforderte Anzahl von Karten.`;
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// Get the authorization header
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
throw new Error('No authorization header');
}
// Extract the Mana app token
const appToken = authHeader.replace('Bearer ', '');
// Get Mana Core JWKS URL from environment variable
const manaJwksUrl = Deno.env.get('JWKS_URL');
if (!manaJwksUrl) {
throw new Error('JWKS_URL not configured');
}
// Verify the Mana token using JWKS
const JWKS = jose.createRemoteJWKSet(new URL(manaJwksUrl));
const { payload } = await jose.jwtVerify(appToken, JWKS);
const userId = payload.sub as string;
if (!userId) {
throw new Error('Invalid token: no user ID');
}
console.log(`Authenticated user: ${userId}`);
// Initialize Supabase client with service role
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Use the userId from the Mana token
const user = { id: userId };
// Parse request body
const requestData = await req.json();
const {
prompt: userPrompt,
deckTitle,
deckDescription = '',
cardCount = 10,
cardTypes = ['flashcard', 'quiz'],
difficulty = 'intermediate',
tags = []
} = requestData;
// Validate input
if (!userPrompt || !deckTitle) {
throw new Error('userPrompt and deckTitle are required');
}
if (cardCount < 1 || cardCount > 50) {
throw new Error('cardCount must be between 1 and 50');
}
// Get OpenAI API key from environment
const openAIApiKey = Deno.env.get('OPENAI_API_KEY');
if (!openAIApiKey) {
throw new Error('OpenAI API key not configured');
}
// Prepare the user message with specific instructions
const userMessage = `
Thema: ${userPrompt}
Anzahl Karten: ${cardCount}
Kartentypen: ${cardTypes.join(', ')}
Schwierigkeit: ${difficulty}
Tags: ${tags.length > 0 ? tags.join(', ') : 'keine spezifischen Tags'}
Erstelle ${cardCount} Lernkarten zum obigen Thema. Mische die Kartentypen ab und stelle sicher, dass die Karten progressiv aufeinander aufbauen.`;
console.log('Generating cards with OpenAI...');
// Call OpenAI API
const openAIResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${openAIApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userMessage }
],
temperature: 0.7,
max_tokens: 4000,
response_format: { type: "json_object" }
})
});
if (!openAIResponse.ok) {
const error = await openAIResponse.text();
console.error('OpenAI API error:', error);
throw new Error(`OpenAI API error: ${error}`);
}
const aiData = await openAIResponse.json();
const content = aiData.choices[0]?.message?.content;
if (!content) {
throw new Error('No content received from OpenAI');
}
// Parse the JSON response
let generatedCards;
try {
const parsed = JSON.parse(content);
generatedCards = Array.isArray(parsed) ? parsed : parsed.cards || parsed.karten || [];
} catch (parseError) {
console.error('Failed to parse OpenAI response:', content);
throw new Error('Invalid response format from AI');
}
if (!Array.isArray(generatedCards) || generatedCards.length === 0) {
throw new Error('No cards generated');
}
console.log(`Generated ${generatedCards.length} cards`);
// Create the deck in the database
const { data: deck, error: deckError } = await supabase
.from('decks')
.insert({
user_id: user.id,
title: deckTitle,
description: deckDescription || `KI-generiertes Deck zum Thema: ${userPrompt}`,
is_public: false,
tags: tags,
metadata: {
ai_generated: true,
generation_prompt: userPrompt,
generation_date: new Date().toISOString(),
model: 'gpt-4o-mini'
}
})
.select()
.single();
if (deckError || !deck) {
console.error('Failed to create deck:', deckError);
throw new Error('Failed to create deck');
}
// Prepare cards for insertion
const cardsToInsert = generatedCards.map((card, index) => ({
deck_id: deck.id,
card_type: card.card_type,
content: card.content,
position: card.position || index + 1,
title: card.title || `Karte ${index + 1}`,
ai_model: 'gpt-4o-mini',
ai_prompt: userPrompt,
version: 1,
is_favorite: false
}));
// Insert all cards
const { data: cards, error: cardsError } = await supabase
.from('cards')
.insert(cardsToInsert)
.select();
if (cardsError) {
console.error('Failed to create cards:', cardsError);
await supabase.from('decks').delete().eq('id', deck.id);
throw new Error('Failed to create cards: ' + JSON.stringify(cardsError));
}
console.log(`Successfully created deck with ${cards?.length} cards`);
// Track the generation (optional)
try {
await supabase.from('ai_generations').insert({
user_id: user.id,
deck_id: deck.id,
function_name: 'generate-deck',
prompt: userPrompt,
model: 'gpt-4o-mini',
status: 'completed',
metadata: {
card_count: cards?.length,
card_types: cardTypes,
difficulty: difficulty
},
completed_at: new Date().toISOString()
});
} catch (trackingError) {
console.log('Could not track generation:', trackingError);
}
// Return success response
return new Response(JSON.stringify({
success: true,
deck: {
id: deck.id,
title: deck.title,
description: deck.description,
card_count: cards?.length || 0
},
cards: cards,
message: `Deck "${deckTitle}" mit ${cards?.length} Karten erfolgreich erstellt!`
}), {
headers: {
...corsHeaders,
'Content-Type': 'application/json'
},
status: 200
});
} catch (error) {
console.error('Error in generate-deck function:', error);
return new Response(JSON.stringify({
success: false,
error: error.message || 'Ein unerwarteter Fehler ist aufgetreten'
}), {
headers: {
...corsHeaders,
'Content-Type': 'application/json'
},
status: error.message?.includes('authorization') ? 401 : 400
});
}
});