mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
cb5657579b
commit
14aace00c2
31 changed files with 2392 additions and 1609 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
8
apps/manadeck/apps/mobile/.env.example
Normal file
8
apps/manadeck/apps/mobile/.env.example
Normal 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
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
365
apps/manadeck/apps/mobile/services/apiClient.ts
Normal file
365
apps/manadeck/apps/mobile/services/apiClient.ts
Normal 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();
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
248
apps/manadeck/apps/web/src/lib/stores/cardStore.svelte.ts
Normal file
248
apps/manadeck/apps/web/src/lib/stores/cardStore.svelte.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
313
apps/manadeck/apps/web/src/lib/stores/progressStore.svelte.ts
Normal file
313
apps/manadeck/apps/web/src/lib/stores/progressStore.svelte.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue