mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
af0eaceb98
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly. UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions. APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes. Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
1659 lines
49 KiB
Markdown
1659 lines
49 KiB
Markdown
# BookDate Feature - AI Agent Implementation Prompt
|
||
|
||
## Task Overview
|
||
|
||
Implement the **BookDate** feature for ReadMeABook - an AI-powered audiobook recommendation system with a Tinder-style swipe interface. This is a complete 0-to-MVP implementation covering backend, frontend, database, and integration work.
|
||
|
||
**PRD Location:** `/home/user/ReadMeABook/documentation/features/bookdate-prd.md`
|
||
|
||
**Goal:** Deliver a working MVP where users can:
|
||
1. Configure AI provider (OpenAI/Claude) in setup wizard or settings
|
||
2. View personalized audiobook recommendations in a swipeable interface
|
||
3. Swipe right to request, left to reject, up to dismiss
|
||
4. Have recommendations improve based on their Plex library and swipe history
|
||
|
||
---
|
||
|
||
## Project Context
|
||
|
||
### Tech Stack
|
||
- **Frontend:** Next.js 14+, TypeScript, Tailwind CSS
|
||
- **Backend:** Next.js API routes (Node.js/Express patterns)
|
||
- **Database:** PostgreSQL with Prisma ORM
|
||
- **Deployment:** Single Docker container
|
||
- **File Structure:** `src/app/` for pages, `src/components/` for UI, `src/lib/` for utilities
|
||
|
||
### Existing Patterns to Follow
|
||
|
||
**Database:**
|
||
- Schema defined in `prisma/schema.prisma`
|
||
- Use `prisma db push` for schema sync (no migrations)
|
||
- Prisma client output: `src/generated/prisma`
|
||
- Encrypted fields: Use AES-256 for API keys (see `backend/services/config.md`)
|
||
|
||
**API Routes:**
|
||
- Location: `src/app/api/[feature]/route.ts`
|
||
- Auth middleware: `requireAuth()`, `requireAdmin()` (see `backend/services/auth.md`)
|
||
- Response format: `NextResponse.json({...})`
|
||
|
||
**Frontend:**
|
||
- Route groups: `(user)` for user pages, `(admin)` for admin
|
||
- Protected routes: Wrap with auth check (see `frontend/routing-auth.md`)
|
||
- Components: Reusable in `src/components/`, page-specific in `src/app/[page]/`
|
||
- Styling: Tailwind CSS, dark mode support
|
||
|
||
**Setup Wizard:**
|
||
- Location: `src/app/setup/`
|
||
- 8-step pattern with progress indicator
|
||
- Current steps: Welcome, Admin, Plex, Prowlarr, Download Client, Paths, Review, Finalize
|
||
- BookDate should be inserted as **step 7** (after Paths validation, before Review)
|
||
- Steps are components: `WelcomeStep.tsx`, `AdminStep.tsx`, etc.
|
||
- State management: Local state passed between steps
|
||
- See `documentation/setup-wizard.md` for structure
|
||
|
||
### Key Documentation to Reference
|
||
|
||
**MANDATORY - Read First:**
|
||
- `documentation/TABLEOFCONTENTS.md` - Navigation guide (read THIS first)
|
||
- `documentation/features/bookdate-prd.md` - Complete feature requirements
|
||
|
||
**For Implementation:**
|
||
- `documentation/backend/database.md` - Schema patterns, encryption
|
||
- `documentation/backend/services/auth.md` - Auth middleware usage
|
||
- `documentation/setup-wizard.md` - Wizard integration patterns
|
||
- `documentation/settings-pages.md` - Settings UI patterns
|
||
- `documentation/frontend/components.md` - UI component catalog
|
||
- `documentation/integrations/plex.md` - Plex API integration patterns
|
||
|
||
---
|
||
|
||
## Implementation Phases
|
||
|
||
### Phase 1: Database Schema (Priority: Critical)
|
||
|
||
**Create new Prisma models in `prisma/schema.prisma`:**
|
||
|
||
```prisma
|
||
model BookDateConfig {
|
||
id String @id @default(uuid())
|
||
userId String @unique
|
||
provider String // 'openai' | 'claude'
|
||
apiKey String // Encrypted at rest
|
||
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5'
|
||
libraryScope String // 'full' | 'listened' | 'rated'
|
||
customPrompt String?
|
||
isVerified Boolean @default(false)
|
||
isEnabled Boolean @default(true) // Admin toggle (global)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId])
|
||
}
|
||
|
||
model BookDateRecommendation {
|
||
id String @id @default(uuid())
|
||
userId String
|
||
batchId String // Group recommendations from same AI call
|
||
title String
|
||
author String
|
||
narrator String?
|
||
rating Float?
|
||
description String?
|
||
coverUrl String?
|
||
audnexusAsin String? // For matching
|
||
aiReason String // Why AI recommended this
|
||
createdAt DateTime @default(now())
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
swipes BookDateSwipe[]
|
||
|
||
@@index([userId, batchId])
|
||
@@index([userId, createdAt])
|
||
}
|
||
|
||
model BookDateSwipe {
|
||
id String @id @default(uuid())
|
||
userId String
|
||
recommendationId String? // NULL if book not from BookDate
|
||
bookTitle String
|
||
bookAuthor String
|
||
action String // 'left' | 'right' | 'up'
|
||
markedAsKnown Boolean @default(false) // True if "Mark as Known"
|
||
createdAt DateTime @default(now())
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
recommendation BookDateRecommendation? @relation(fields: [recommendationId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([userId, createdAt])
|
||
@@index([recommendationId])
|
||
}
|
||
```
|
||
|
||
**User model update:**
|
||
Add relationships:
|
||
```prisma
|
||
model User {
|
||
// ... existing fields ...
|
||
bookDateConfig BookDateConfig?
|
||
bookDateRecommendations BookDateRecommendation[]
|
||
bookDateSwipes BookDateSwipe[]
|
||
}
|
||
```
|
||
|
||
**After schema changes:**
|
||
1. Run `npx prisma db push` to sync schema
|
||
2. Run `npx prisma generate` to regenerate client
|
||
3. Verify in database that tables created correctly
|
||
|
||
**Encryption:**
|
||
- API keys in `BookDateConfig.apiKey` MUST be encrypted
|
||
- Use existing encryption utility (see `backend/services/config.md`)
|
||
- Pattern: `encrypt(apiKey)` before save, `decrypt(apiKey)` on read
|
||
|
||
---
|
||
|
||
### Phase 2: Backend API - Configuration (Priority: Critical)
|
||
|
||
**Create API routes in `src/app/api/bookdate/`:**
|
||
|
||
#### 2.1 Test Connection & Fetch Models
|
||
|
||
**File:** `src/app/api/bookdate/test-connection/route.ts`
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { requireAuth } from '@/lib/auth'; // Adjust path as needed
|
||
|
||
export async function POST(req: NextRequest) {
|
||
// Auth check
|
||
const user = await requireAuth(req);
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
const { provider, apiKey } = await req.json();
|
||
|
||
// Validate inputs
|
||
if (!provider || !apiKey) {
|
||
return NextResponse.json({ error: 'Provider and API key required' }, { status: 400 });
|
||
}
|
||
|
||
try {
|
||
let models = [];
|
||
|
||
if (provider === 'openai') {
|
||
// OpenAI: Fetch models from https://api.openai.com/v1/models
|
||
const response = await fetch('https://api.openai.com/v1/models', {
|
||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||
});
|
||
|
||
if (!response.ok) {
|
||
return NextResponse.json({ error: 'Invalid OpenAI API key' }, { status: 400 });
|
||
}
|
||
|
||
const data = await response.json();
|
||
// Filter to relevant models (gpt-4o, gpt-4-turbo, etc.)
|
||
models = data.data
|
||
.filter((m: any) => m.id.startsWith('gpt-'))
|
||
.map((m: any) => ({ id: m.id, name: m.id }));
|
||
|
||
} else if (provider === 'claude') {
|
||
// Claude: Fetch models dynamically from the Anthropic Models API
|
||
const response = await fetch('https://api.anthropic.com/v1/models?limit=1000', {
|
||
headers: {
|
||
'x-api-key': apiKey,
|
||
'anthropic-version': '2023-06-01',
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 });
|
||
}
|
||
|
||
const data = await response.json();
|
||
models = data.data.map((m: any) => ({
|
||
id: m.id,
|
||
name: m.display_name || m.id,
|
||
}));
|
||
} else {
|
||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||
}
|
||
|
||
return NextResponse.json({ success: true, models });
|
||
|
||
} catch (error: any) {
|
||
console.error('[BookDate] Test connection error:', error);
|
||
return NextResponse.json({ error: error.message || 'Connection failed' }, { status: 500 });
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.2 Save/Update Configuration
|
||
|
||
**File:** `src/app/api/bookdate/config/route.ts`
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { requireAuth } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma'; // Adjust path
|
||
import { encrypt, decrypt } from '@/lib/encryption'; // Adjust path
|
||
|
||
// GET: Fetch user's config (excluding API key)
|
||
export async function GET(req: NextRequest) {
|
||
const user = await requireAuth(req);
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
const config = await prisma.bookDateConfig.findUnique({
|
||
where: { userId: user.id }
|
||
});
|
||
|
||
if (!config) {
|
||
return NextResponse.json({ config: null });
|
||
}
|
||
|
||
// Don't return API key
|
||
const { apiKey, ...safeConfig } = config;
|
||
|
||
return NextResponse.json({ config: safeConfig });
|
||
}
|
||
|
||
// POST: Create/update config
|
||
export async function POST(req: NextRequest) {
|
||
const user = await requireAuth(req);
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
const { provider, apiKey, model, libraryScope, customPrompt } = await req.json();
|
||
|
||
// Validation
|
||
if (!provider || !apiKey || !model || !libraryScope) {
|
||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||
}
|
||
|
||
if (!['openai', 'claude'].includes(provider)) {
|
||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||
}
|
||
|
||
if (!['full', 'listened', 'rated'].includes(libraryScope)) {
|
||
return NextResponse.json({ error: 'Invalid library scope' }, { status: 400 });
|
||
}
|
||
|
||
try {
|
||
// Encrypt API key
|
||
const encryptedApiKey = encrypt(apiKey);
|
||
|
||
// Upsert config
|
||
const config = await prisma.bookDateConfig.upsert({
|
||
where: { userId: user.id },
|
||
update: {
|
||
provider,
|
||
apiKey: encryptedApiKey,
|
||
model,
|
||
libraryScope,
|
||
customPrompt: customPrompt || null,
|
||
isVerified: true,
|
||
updatedAt: new Date()
|
||
},
|
||
create: {
|
||
userId: user.id,
|
||
provider,
|
||
apiKey: encryptedApiKey,
|
||
model,
|
||
libraryScope,
|
||
customPrompt: customPrompt || null,
|
||
isVerified: true
|
||
}
|
||
});
|
||
|
||
// Clear cached recommendations when config changes
|
||
await prisma.bookDateRecommendation.deleteMany({
|
||
where: { userId: user.id }
|
||
});
|
||
|
||
return NextResponse.json({ success: true, config: { ...config, apiKey: undefined } });
|
||
|
||
} catch (error: any) {
|
||
console.error('[BookDate] Save config error:', error);
|
||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
// DELETE: Remove config
|
||
export async function DELETE(req: NextRequest) {
|
||
const user = await requireAuth(req);
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
await prisma.bookDateConfig.delete({
|
||
where: { userId: user.id }
|
||
});
|
||
|
||
return NextResponse.json({ success: true });
|
||
}
|
||
```
|
||
|
||
#### 2.3 Admin Toggle (Global Enable/Disable)
|
||
|
||
**File:** `src/app/api/admin/bookdate/toggle/route.ts`
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { requireAdmin } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
|
||
export async function PATCH(req: NextRequest) {
|
||
const admin = await requireAdmin(req);
|
||
if (!admin) {
|
||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||
}
|
||
|
||
const { isEnabled } = await req.json();
|
||
|
||
// Update all configs
|
||
await prisma.bookDateConfig.updateMany({
|
||
data: { isEnabled }
|
||
});
|
||
|
||
return NextResponse.json({ success: true, isEnabled });
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 3: Backend API - Recommendations (Priority: Critical)
|
||
|
||
#### 3.1 Helper Functions
|
||
|
||
**File:** `src/lib/bookdate/helpers.ts`
|
||
|
||
Create utility functions:
|
||
|
||
```typescript
|
||
import { prisma } from '@/lib/prisma';
|
||
import { decrypt } from '@/lib/encryption';
|
||
|
||
// Get user's Plex library books based on scope
|
||
export async function getUserLibraryBooks(userId: string, scope: 'full' | 'listened' | 'rated') {
|
||
// Query Plex API or database cache
|
||
// For 'full': Return all audiobooks in user's Plex library
|
||
// For 'listened': Filter by viewOffset/duration > 25%
|
||
// For 'rated': Filter by user ratings > 0
|
||
|
||
// Implementation note: Use existing Plex integration patterns
|
||
// See documentation/integrations/plex.md
|
||
|
||
// Return format:
|
||
return [
|
||
{
|
||
title: 'Example Book',
|
||
author: 'Author Name',
|
||
narrator: 'Narrator Name',
|
||
genres: ['Fiction', 'Sci-Fi'],
|
||
rating: 4.5,
|
||
listenStatus: 'completed' // or 'partial', 'unplayed'
|
||
}
|
||
// ... up to 40 latest books
|
||
];
|
||
}
|
||
|
||
// Get user's recent swipes
|
||
export async function getUserRecentSwipes(userId: string, limit: number = 10) {
|
||
const swipes = await prisma.bookDateSwipe.findMany({
|
||
where: { userId },
|
||
orderBy: { createdAt: 'desc' },
|
||
take: limit,
|
||
select: {
|
||
bookTitle: true,
|
||
bookAuthor: true,
|
||
action: true,
|
||
createdAt: true
|
||
}
|
||
});
|
||
|
||
return swipes.map(s => ({
|
||
title: s.bookTitle,
|
||
author: s.bookAuthor,
|
||
action: s.action
|
||
}));
|
||
}
|
||
|
||
// Build AI prompt
|
||
export async function buildAIPrompt(userId: string, config: any) {
|
||
const { libraryScope, customPrompt } = config;
|
||
|
||
// Get context (max 50 books)
|
||
const libraryBooks = await getUserLibraryBooks(userId, libraryScope);
|
||
const swipeHistory = await getUserRecentSwipes(userId, 10);
|
||
|
||
// Determine split (40 library + 10 swipes, adjust if needed)
|
||
const maxLibraryBooks = Math.min(libraryBooks.length, 40);
|
||
const contextBooks = libraryBooks.slice(0, maxLibraryBooks);
|
||
|
||
const prompt = {
|
||
task: 'recommend_audiobooks',
|
||
user_context: {
|
||
library_books: contextBooks,
|
||
swipe_history: swipeHistory,
|
||
custom_preferences: customPrompt || null
|
||
},
|
||
instructions: 'Based on the user\'s library and swipe history, recommend 20 audiobooks they would enjoy. Exclude books already in their library. Focus on variety and quality. Return ONLY valid JSON.',
|
||
response_format: {
|
||
recommendations: [
|
||
{
|
||
title: 'string',
|
||
author: 'string',
|
||
reason: '1-2 sentence explanation'
|
||
}
|
||
]
|
||
}
|
||
};
|
||
|
||
return JSON.stringify(prompt);
|
||
}
|
||
|
||
// Call AI API
|
||
export async function callAI(provider: string, model: string, apiKey: string, prompt: string) {
|
||
const decryptedKey = decrypt(apiKey);
|
||
|
||
if (provider === 'openai') {
|
||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${decryptedKey}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model,
|
||
response_format: { type: 'json_object' },
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: 'You are an expert audiobook recommender. Return ONLY valid JSON.'
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: prompt
|
||
}
|
||
]
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`OpenAI API error: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
return JSON.parse(data.choices[0].message.content);
|
||
|
||
} else if (provider === 'claude') {
|
||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||
method: 'POST',
|
||
headers: {
|
||
'x-api-key': decryptedKey,
|
||
'anthropic-version': '2023-06-01',
|
||
'content-type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model,
|
||
max_tokens: 4096,
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: `${prompt}\n\nReturn ONLY valid JSON with no additional text or formatting.`
|
||
}
|
||
]
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Claude API error: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
const content = data.content[0].text;
|
||
return JSON.parse(content);
|
||
}
|
||
|
||
throw new Error('Invalid provider');
|
||
}
|
||
|
||
// Match AI recommendation to Audnexus
|
||
export async function matchToAudnexus(title: string, author: string) {
|
||
// Search Audnexus API for title + author
|
||
// Use existing Audnexus integration patterns
|
||
// Return metadata or null if no match
|
||
|
||
// Implementation note: Similar to existing Audible search
|
||
// See integrations/audible.md or existing Audible API code
|
||
|
||
return {
|
||
asin: 'B0XXXXXX',
|
||
title: 'Matched Title',
|
||
author: 'Matched Author',
|
||
narrator: 'Narrator Name',
|
||
rating: 4.5,
|
||
description: 'Book description...',
|
||
coverUrl: 'https://...'
|
||
};
|
||
}
|
||
```
|
||
|
||
#### 3.2 Get Recommendations
|
||
|
||
**File:** `src/app/api/bookdate/recommendations/route.ts`
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { requireAuth } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
import { buildAIPrompt, callAI, matchToAudnexus } from '@/lib/bookdate/helpers';
|
||
|
||
export async function GET(req: NextRequest) {
|
||
const user = await requireAuth(req);
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
try {
|
||
// Check for cached recommendations
|
||
const cached = await prisma.bookDateRecommendation.findMany({
|
||
where: { userId: user.id },
|
||
orderBy: { createdAt: 'asc' },
|
||
take: 10
|
||
});
|
||
|
||
if (cached.length >= 10) {
|
||
return NextResponse.json({ recommendations: cached, source: 'cache' });
|
||
}
|
||
|
||
// Need to generate new recommendations
|
||
const config = await prisma.bookDateConfig.findUnique({
|
||
where: { userId: user.id }
|
||
});
|
||
|
||
if (!config || !config.isVerified || !config.isEnabled) {
|
||
return NextResponse.json({ error: 'BookDate not configured' }, { status: 400 });
|
||
}
|
||
|
||
// Build prompt and call AI
|
||
const prompt = await buildAIPrompt(user.id, config);
|
||
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt);
|
||
|
||
if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) {
|
||
throw new Error('Invalid AI response format');
|
||
}
|
||
|
||
// Match to Audnexus and filter
|
||
const batchId = `batch_${Date.now()}`;
|
||
const matched = [];
|
||
|
||
for (const rec of aiResponse.recommendations) {
|
||
// Check if already in library (skip)
|
||
// Check if already requested (skip)
|
||
// Check if already swiped (skip)
|
||
const alreadySwiped = await prisma.bookDateSwipe.findFirst({
|
||
where: {
|
||
userId: user.id,
|
||
bookTitle: rec.title,
|
||
bookAuthor: rec.author
|
||
}
|
||
});
|
||
|
||
if (alreadySwiped) continue;
|
||
|
||
// Match to Audnexus
|
||
try {
|
||
const audnexusMatch = await matchToAudnexus(rec.title, rec.author);
|
||
if (!audnexusMatch) {
|
||
console.warn(`[BookDate] No Audnexus match: "${rec.title}" by ${rec.author}`);
|
||
continue;
|
||
}
|
||
|
||
matched.push({
|
||
userId: user.id,
|
||
batchId,
|
||
title: audnexusMatch.title,
|
||
author: audnexusMatch.author,
|
||
narrator: audnexusMatch.narrator,
|
||
rating: audnexusMatch.rating,
|
||
description: audnexusMatch.description,
|
||
coverUrl: audnexusMatch.coverUrl,
|
||
audnexusAsin: audnexusMatch.asin,
|
||
aiReason: rec.reason
|
||
});
|
||
|
||
if (matched.length >= 10) break;
|
||
|
||
} catch (error) {
|
||
console.warn(`[BookDate] Match error for "${rec.title}":`, error);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Save to database
|
||
if (matched.length > 0) {
|
||
await prisma.bookDateRecommendation.createMany({
|
||
data: matched
|
||
});
|
||
}
|
||
|
||
// Combine with existing cache
|
||
const allRecommendations = await prisma.bookDateRecommendation.findMany({
|
||
where: { userId: user.id },
|
||
orderBy: { createdAt: 'asc' },
|
||
take: 10
|
||
});
|
||
|
||
return NextResponse.json({
|
||
recommendations: allRecommendations,
|
||
source: 'generated',
|
||
generatedCount: matched.length
|
||
});
|
||
|
||
} catch (error: any) {
|
||
console.error('[BookDate] Recommendations error:', error);
|
||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3 Record Swipe
|
||
|
||
**File:** `src/app/api/bookdate/swipe/route.ts`
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { requireAuth } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
|
||
export async function POST(req: NextRequest) {
|
||
const user = await requireAuth(req);
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
const { recommendationId, action, markedAsKnown } = await req.json();
|
||
|
||
if (!recommendationId || !action) {
|
||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||
}
|
||
|
||
if (!['left', 'right', 'up'].includes(action)) {
|
||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||
}
|
||
|
||
try {
|
||
// Get recommendation
|
||
const recommendation = await prisma.bookDateRecommendation.findUnique({
|
||
where: { id: recommendationId }
|
||
});
|
||
|
||
if (!recommendation || recommendation.userId !== user.id) {
|
||
return NextResponse.json({ error: 'Recommendation not found' }, { status: 404 });
|
||
}
|
||
|
||
// Record swipe
|
||
await prisma.bookDateSwipe.create({
|
||
data: {
|
||
userId: user.id,
|
||
recommendationId,
|
||
bookTitle: recommendation.title,
|
||
bookAuthor: recommendation.author,
|
||
action,
|
||
markedAsKnown: markedAsKnown || false
|
||
}
|
||
});
|
||
|
||
// Remove from cache
|
||
await prisma.bookDateRecommendation.delete({
|
||
where: { id: recommendationId }
|
||
});
|
||
|
||
return NextResponse.json({ success: true });
|
||
|
||
} catch (error: any) {
|
||
console.error('[BookDate] Swipe error:', error);
|
||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.4 Undo Swipe
|
||
|
||
**File:** `src/app/api/bookdate/undo/route.ts`
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { requireAuth } from '@/lib/auth';
|
||
import { prisma } from '@/lib/prisma';
|
||
|
||
export async function POST(req: NextRequest) {
|
||
const user = await requireAuth(req);
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
try {
|
||
// Get last swipe (left or up only)
|
||
const lastSwipe = await prisma.bookDateSwipe.findFirst({
|
||
where: {
|
||
userId: user.id,
|
||
action: { in: ['left', 'up'] }
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
include: { recommendation: true }
|
||
});
|
||
|
||
if (!lastSwipe) {
|
||
return NextResponse.json({ error: 'No swipe to undo' }, { status: 404 });
|
||
}
|
||
|
||
// Restore recommendation to cache (if available)
|
||
if (lastSwipe.recommendation) {
|
||
await prisma.bookDateRecommendation.create({
|
||
data: {
|
||
userId: user.id,
|
||
batchId: lastSwipe.recommendation.batchId,
|
||
title: lastSwipe.recommendation.title,
|
||
author: lastSwipe.recommendation.author,
|
||
narrator: lastSwipe.recommendation.narrator,
|
||
rating: lastSwipe.recommendation.rating,
|
||
description: lastSwipe.recommendation.description,
|
||
coverUrl: lastSwipe.recommendation.coverUrl,
|
||
audnexusAsin: lastSwipe.recommendation.audnexusAsin,
|
||
aiReason: lastSwipe.recommendation.aiReason
|
||
}
|
||
});
|
||
}
|
||
|
||
// Delete swipe
|
||
await prisma.bookDateSwipe.delete({
|
||
where: { id: lastSwipe.id }
|
||
});
|
||
|
||
return NextResponse.json({ success: true, recommendation: lastSwipe.recommendation });
|
||
|
||
} catch (error: any) {
|
||
console.error('[BookDate] Undo error:', error);
|
||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.5 Generate More
|
||
|
||
**File:** `src/app/api/bookdate/generate/route.ts`
|
||
|
||
Similar to recommendations endpoint, but forces new generation (doesn't check cache first).
|
||
|
||
---
|
||
|
||
### Phase 4: Setup Wizard Integration (Priority: High)
|
||
|
||
**Goal:** Add BookDate configuration as step 7 in the setup wizard (after Paths, before Review).
|
||
|
||
**Files to modify:**
|
||
- `src/app/setup/page.tsx` - Main wizard component
|
||
- Create `src/app/setup/BookDateStep.tsx` - New step component
|
||
|
||
**BookDateStep.tsx structure:**
|
||
|
||
```typescript
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
|
||
interface BookDateStepProps {
|
||
onNext: (data: any) => void;
|
||
onSkip: () => void;
|
||
}
|
||
|
||
export default function BookDateStep({ onNext, onSkip }: BookDateStepProps) {
|
||
const [provider, setProvider] = useState<'openai' | 'claude'>('openai');
|
||
const [apiKey, setApiKey] = useState('');
|
||
const [models, setModels] = useState<any[]>([]);
|
||
const [selectedModel, setSelectedModel] = useState('');
|
||
const [libraryScope, setLibraryScope] = useState<'full' | 'listened' | 'rated'>('full');
|
||
const [customPrompt, setCustomPrompt] = useState('');
|
||
const [testing, setTesting] = useState(false);
|
||
const [tested, setTested] = useState(false);
|
||
|
||
const handleTestConnection = async () => {
|
||
setTesting(true);
|
||
try {
|
||
const response = await fetch('/api/bookdate/test-connection', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ provider, apiKey })
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
setModels(data.models);
|
||
setTested(true);
|
||
} else {
|
||
alert(data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Connection test failed');
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
};
|
||
|
||
const handleNext = () => {
|
||
onNext({ provider, apiKey, model: selectedModel, libraryScope, customPrompt });
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-2xl font-bold">BookDate Setup (Optional)</h2>
|
||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||
Configure AI-powered audiobook recommendations. You can skip this and set it up later.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Provider selection */}
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">AI Provider</label>
|
||
<select
|
||
value={provider}
|
||
onChange={(e) => setProvider(e.target.value as any)}
|
||
className="w-full px-4 py-2 border rounded-lg"
|
||
>
|
||
<option value="openai">OpenAI</option>
|
||
<option value="claude">Claude (Anthropic)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* API key input */}
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">API Key</label>
|
||
<input
|
||
type="password"
|
||
value={apiKey}
|
||
onChange={(e) => setApiKey(e.target.value)}
|
||
placeholder="sk-..."
|
||
className="w-full px-4 py-2 border rounded-lg"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Your API key is stored securely and only used for recommendations
|
||
</p>
|
||
</div>
|
||
|
||
{/* Test connection button */}
|
||
<button
|
||
onClick={handleTestConnection}
|
||
disabled={!apiKey || testing}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
|
||
>
|
||
{testing ? 'Testing...' : 'Test Connection & Fetch Models'}
|
||
</button>
|
||
|
||
{/* Model selection (only shown after successful test) */}
|
||
{tested && models.length > 0 && (
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">Select Model</label>
|
||
<select
|
||
value={selectedModel}
|
||
onChange={(e) => setSelectedModel(e.target.value)}
|
||
className="w-full px-4 py-2 border rounded-lg"
|
||
>
|
||
<option value="">-- Choose a model --</option>
|
||
{models.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Library scope */}
|
||
{tested && selectedModel && (
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">Base Recommendations On</label>
|
||
<div className="space-y-2">
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
value="full"
|
||
checked={libraryScope === 'full'}
|
||
onChange={(e) => setLibraryScope(e.target.value as any)}
|
||
className="mr-2"
|
||
/>
|
||
Full Plex Library
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
value="listened"
|
||
checked={libraryScope === 'listened'}
|
||
onChange={(e) => setLibraryScope(e.target.value as any)}
|
||
className="mr-2"
|
||
/>
|
||
Listened Books Only (>25% completed)
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
value="rated"
|
||
checked={libraryScope === 'rated'}
|
||
onChange={(e) => setLibraryScope(e.target.value as any)}
|
||
className="mr-2"
|
||
/>
|
||
Rated Books Only
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Custom prompt */}
|
||
{tested && selectedModel && (
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">
|
||
Additional Preferences (Optional)
|
||
</label>
|
||
<textarea
|
||
value={customPrompt}
|
||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||
placeholder="e.g., 'I prefer sci-fi with strong female leads'"
|
||
rows={3}
|
||
className="w-full px-4 py-2 border rounded-lg"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Navigation buttons */}
|
||
<div className="flex gap-4">
|
||
<button
|
||
onClick={onSkip}
|
||
className="px-4 py-2 border rounded-lg"
|
||
>
|
||
Skip for now
|
||
</button>
|
||
<button
|
||
onClick={handleNext}
|
||
disabled={!tested || !selectedModel || !libraryScope}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Integration points:**
|
||
1. Import `BookDateStep` in main wizard
|
||
2. Add to step array between Paths (step 6) and Review (step 7)
|
||
3. Handle skip logic (don't save config if skipped)
|
||
4. In Review step, show BookDate config if configured
|
||
5. In Finalize step, save BookDate config if provided
|
||
|
||
---
|
||
|
||
### Phase 5: Settings Page Integration (Priority: High)
|
||
|
||
**File:** `src/app/(user)/settings/bookdate/page.tsx`
|
||
|
||
Create new settings page for BookDate:
|
||
|
||
```typescript
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
|
||
export default function BookDateSettings() {
|
||
// Similar structure to BookDateStep, but with:
|
||
// - Load existing config on mount
|
||
// - Save button instead of Next
|
||
// - Clear swipe history button
|
||
// - Admin toggle (if user is admin)
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto p-6">
|
||
<h1 className="text-3xl font-bold mb-6">BookDate Settings</h1>
|
||
|
||
{/* Same fields as wizard step */}
|
||
{/* Add: Clear Swipe History button */}
|
||
{/* Add: Admin toggle (if admin role) */}
|
||
|
||
<button
|
||
onClick={handleSave}
|
||
className="px-6 py-2 bg-blue-600 text-white rounded-lg"
|
||
>
|
||
Save Settings
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Add to main settings nav:**
|
||
- Modify `src/app/(user)/settings/page.tsx` or settings layout
|
||
- Add "BookDate" link to settings navigation
|
||
|
||
---
|
||
|
||
### Phase 6: BookDate Tab & UI (Priority: Critical)
|
||
|
||
#### 6.1 BookDate Page
|
||
|
||
**File:** `src/app/(user)/bookdate/page.tsx`
|
||
|
||
```typescript
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import RecommendationCard from '@/components/bookdate/RecommendationCard';
|
||
import LoadingScreen from '@/components/bookdate/LoadingScreen';
|
||
|
||
export default function BookDatePage() {
|
||
const [recommendations, setRecommendations] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [currentIndex, setCurrentIndex] = useState(0);
|
||
const [lastSwipe, setLastSwipe] = useState<any>(null);
|
||
const router = useRouter();
|
||
|
||
useEffect(() => {
|
||
loadRecommendations();
|
||
}, []);
|
||
|
||
const loadRecommendations = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await fetch('/api/bookdate/recommendations');
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
setError(data.error);
|
||
return;
|
||
}
|
||
|
||
setRecommendations(data.recommendations);
|
||
} catch (error: any) {
|
||
setError(error.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSwipe = async (action: 'left' | 'right' | 'up', markedAsKnown = false) => {
|
||
const recommendation = recommendations[currentIndex];
|
||
|
||
try {
|
||
await fetch('/api/bookdate/swipe', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
recommendationId: recommendation.id,
|
||
action,
|
||
markedAsKnown
|
||
})
|
||
});
|
||
|
||
setLastSwipe({ recommendation, action });
|
||
setCurrentIndex(currentIndex + 1);
|
||
|
||
// Check if we've reached the end
|
||
if (currentIndex + 1 >= recommendations.length) {
|
||
// Show empty state
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Swipe error:', error);
|
||
}
|
||
};
|
||
|
||
const handleUndo = async () => {
|
||
// Only for left/up swipes
|
||
if (!lastSwipe || lastSwipe.action === 'right') return;
|
||
|
||
try {
|
||
const response = await fetch('/api/bookdate/undo', {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (response.ok) {
|
||
setCurrentIndex(currentIndex - 1);
|
||
setLastSwipe(null);
|
||
}
|
||
} catch (error) {
|
||
console.error('Undo error:', error);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return <LoadingScreen />;
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||
<h2 className="text-2xl font-bold mb-4">⚠️ Could not load recommendations</h2>
|
||
<p className="text-gray-600 mb-4">{error}</p>
|
||
<div className="flex gap-4">
|
||
<button onClick={loadRecommendations} className="px-4 py-2 bg-blue-600 text-white rounded-lg">
|
||
Try Again
|
||
</button>
|
||
<button onClick={() => router.push('/settings/bookdate')} className="px-4 py-2 border rounded-lg">
|
||
Go to Settings
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (currentIndex >= recommendations.length) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||
<h2 className="text-2xl font-bold mb-4">🎉 You've seen all our current recommendations!</h2>
|
||
<p className="text-gray-600 mb-4">Want more suggestions?</p>
|
||
<div className="flex gap-4">
|
||
<button onClick={loadRecommendations} className="px-4 py-2 bg-blue-600 text-white rounded-lg">
|
||
Get More
|
||
</button>
|
||
<button onClick={() => router.push('/')} className="px-4 py-2 border rounded-lg">
|
||
Go Home
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const currentRec = recommendations[currentIndex];
|
||
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-screen p-4">
|
||
<RecommendationCard
|
||
recommendation={currentRec}
|
||
onSwipe={handleSwipe}
|
||
/>
|
||
|
||
{/* Undo button (show briefly after left/up swipe) */}
|
||
{lastSwipe && lastSwipe.action !== 'right' && (
|
||
<button
|
||
onClick={handleUndo}
|
||
className="fixed bottom-8 left-8 px-4 py-2 bg-gray-800 text-white rounded-lg"
|
||
>
|
||
Undo
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 6.2 Recommendation Card Component
|
||
|
||
**File:** `src/components/bookdate/RecommendationCard.tsx`
|
||
|
||
```typescript
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import Image from 'next/image';
|
||
import { useSwipeable } from 'react-swipeable'; // Install: npm install react-swipeable
|
||
|
||
interface RecommendationCardProps {
|
||
recommendation: any;
|
||
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
|
||
}
|
||
|
||
export default function RecommendationCard({ recommendation, onSwipe }: RecommendationCardProps) {
|
||
const [showToast, setShowToast] = useState(false);
|
||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||
|
||
const handleSwipeRight = () => {
|
||
// Show confirmation toast
|
||
setShowToast(true);
|
||
};
|
||
|
||
const handleToastAction = (action: 'request' | 'known' | 'cancel') => {
|
||
setShowToast(false);
|
||
if (action === 'request') {
|
||
onSwipe('right', false);
|
||
} else if (action === 'known') {
|
||
onSwipe('right', true);
|
||
}
|
||
};
|
||
|
||
const swipeHandlers = useSwipeable({
|
||
onSwipedLeft: () => onSwipe('left'),
|
||
onSwipedRight: handleSwipeRight,
|
||
onSwipedUp: () => onSwipe('up'),
|
||
onSwiping: (eventData) => {
|
||
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
|
||
},
|
||
trackMouse: true
|
||
});
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
{...swipeHandlers}
|
||
className="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden"
|
||
style={{
|
||
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`,
|
||
transition: dragOffset.x === 0 ? 'transform 0.3s' : 'none'
|
||
}}
|
||
>
|
||
{/* Overlay based on drag direction */}
|
||
{dragOffset.x > 50 && (
|
||
<div className="absolute inset-0 bg-green-500 bg-opacity-30 flex items-center justify-center">
|
||
<span className="text-6xl">✅</span>
|
||
</div>
|
||
)}
|
||
{dragOffset.x < -50 && (
|
||
<div className="absolute inset-0 bg-red-500 bg-opacity-30 flex items-center justify-center">
|
||
<span className="text-6xl">❌</span>
|
||
</div>
|
||
)}
|
||
{dragOffset.y < -50 && (
|
||
<div className="absolute inset-0 bg-blue-500 bg-opacity-30 flex items-center justify-center">
|
||
<span className="text-6xl">⬆️</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Cover image */}
|
||
<div className="w-full h-96 relative">
|
||
{recommendation.coverUrl ? (
|
||
<Image
|
||
src={recommendation.coverUrl}
|
||
alt={recommendation.title}
|
||
fill
|
||
className="object-cover"
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full bg-gray-300 dark:bg-gray-700 flex items-center justify-center">
|
||
<span className="text-4xl">📚</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="p-6">
|
||
<h3 className="text-2xl font-bold mb-2">{recommendation.title}</h3>
|
||
<p className="text-gray-600 dark:text-gray-400 mb-1">{recommendation.author}</p>
|
||
{recommendation.narrator && (
|
||
<p className="text-sm text-gray-500 mb-3">Narrated by {recommendation.narrator}</p>
|
||
)}
|
||
{recommendation.rating && (
|
||
<div className="flex items-center mb-3">
|
||
<span className="text-yellow-500">⭐</span>
|
||
<span className="ml-1">{recommendation.rating}</span>
|
||
</div>
|
||
)}
|
||
{recommendation.description && (
|
||
<p className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4">
|
||
{recommendation.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Desktop buttons */}
|
||
<div className="hidden md:flex justify-center gap-4 p-6">
|
||
<button
|
||
onClick={() => onSwipe('left')}
|
||
className="px-6 py-3 bg-red-500 text-white rounded-full hover:bg-red-600"
|
||
>
|
||
❌ Not Interested
|
||
</button>
|
||
<button
|
||
onClick={() => onSwipe('up')}
|
||
className="px-6 py-3 bg-blue-500 text-white rounded-full hover:bg-blue-600"
|
||
>
|
||
⬆️ Dismiss
|
||
</button>
|
||
<button
|
||
onClick={handleSwipeRight}
|
||
className="px-6 py-3 bg-green-500 text-white rounded-full hover:bg-green-600"
|
||
>
|
||
✅ Request
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Confirmation Toast */}
|
||
{showToast && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md">
|
||
<h3 className="text-xl font-bold mb-4">Request "{recommendation.title}"?</h3>
|
||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||
Do you want to request this audiobook, or have you already read/listened to it elsewhere?
|
||
</p>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => handleToastAction('known')}
|
||
className="px-4 py-2 border rounded-lg"
|
||
>
|
||
Mark as Known
|
||
</button>
|
||
<button
|
||
onClick={() => handleToastAction('request')}
|
||
className="px-4 py-2 bg-green-600 text-white rounded-lg"
|
||
>
|
||
Request
|
||
</button>
|
||
<button
|
||
onClick={() => handleToastAction('cancel')}
|
||
className="px-4 py-2 border rounded-lg"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 6.3 Loading Screen Component
|
||
|
||
**File:** `src/components/bookdate/LoadingScreen.tsx`
|
||
|
||
```typescript
|
||
export default function LoadingScreen() {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||
{/* Animated book cards shuffling */}
|
||
<div className="relative w-64 h-96 mb-8">
|
||
<div className="absolute inset-0 bg-blue-500 rounded-lg animate-pulse" />
|
||
<div className="absolute inset-0 bg-green-500 rounded-lg animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||
<div className="absolute inset-0 bg-purple-500 rounded-lg animate-ping" style={{ animationDelay: '0.4s' }} />
|
||
</div>
|
||
<p className="text-xl text-gray-600 dark:text-gray-400">Finding your next great listen...</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 6.4 Tab Visibility
|
||
|
||
**File:** `src/components/layout/Header.tsx` (or wherever navigation is)
|
||
|
||
Add conditional BookDate tab:
|
||
|
||
```typescript
|
||
// Check if user has BookDate configured
|
||
const [showBookDate, setShowBookDate] = useState(false);
|
||
|
||
useEffect(() => {
|
||
async function checkBookDate() {
|
||
const response = await fetch('/api/bookdate/config');
|
||
const data = await response.json();
|
||
setShowBookDate(data.config && data.config.isVerified && data.config.isEnabled);
|
||
}
|
||
checkBookDate();
|
||
}, []);
|
||
|
||
// In navigation:
|
||
{showBookDate && (
|
||
<Link href="/bookdate">BookDate</Link>
|
||
)}
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 7: Integration & Polish (Priority: Medium)
|
||
|
||
#### 7.1 Request Integration
|
||
|
||
When user swipes right and confirms "Request":
|
||
- Call existing request API: `POST /api/requests` with `{ asin: recommendation.audnexusAsin }`
|
||
- Show success toast
|
||
- Continue to next recommendation
|
||
|
||
#### 7.2 Plex Library Integration
|
||
|
||
Implement `getUserLibraryBooks()` helper:
|
||
- Query Plex API for user's audiobook library
|
||
- For 'listened' scope: Calculate `viewOffset / duration > 0.25`
|
||
- For 'rated' scope: Filter books with user ratings
|
||
- Return latest 40 books
|
||
|
||
**Reference:** See `documentation/integrations/plex.md` for existing Plex API patterns
|
||
|
||
#### 7.3 Audnexus Matching
|
||
|
||
Implement `matchToAudnexus()` helper:
|
||
- Search Audnexus API by title + author
|
||
- Fuzzy match if exact fails
|
||
- Return metadata or null
|
||
|
||
**Reference:** Existing Audible search code or `documentation/integrations/audible.md`
|
||
|
||
#### 7.4 Error Logging
|
||
|
||
Add comprehensive logging:
|
||
- API errors
|
||
- Audnexus match failures
|
||
- AI response parsing errors
|
||
|
||
#### 7.5 Dark Mode Support
|
||
|
||
Ensure all components support dark mode:
|
||
- Use Tailwind `dark:` variants
|
||
- Test in both modes
|
||
|
||
---
|
||
|
||
### Phase 8: Testing & Verification (Priority: High)
|
||
|
||
#### 8.1 Database Testing
|
||
- [ ] Run `npx prisma db push` successfully
|
||
- [ ] Verify tables created in PostgreSQL
|
||
- [ ] Test encrypted API key storage/retrieval
|
||
- [ ] Test cascade deletes (user deletion removes configs/recommendations/swipes)
|
||
|
||
#### 8.2 API Testing
|
||
- [ ] Test connection endpoint with valid/invalid API keys
|
||
- [ ] Test config save/update/delete
|
||
- [ ] Test recommendation generation
|
||
- [ ] Test swipe recording
|
||
- [ ] Test undo functionality
|
||
- [ ] Test admin toggle
|
||
|
||
#### 8.3 UI Testing
|
||
- [ ] Setup wizard step works (skip functionality)
|
||
- [ ] Settings page loads and saves config
|
||
- [ ] BookDate tab shows/hides based on config
|
||
- [ ] Swipe gestures work on mobile
|
||
- [ ] Desktop buttons work
|
||
- [ ] Loading screen displays correctly
|
||
- [ ] Empty state shows at end
|
||
- [ ] Undo button appears/works
|
||
- [ ] Toast confirmation works
|
||
|
||
#### 8.4 Integration Testing
|
||
- [ ] Request creation from right swipe
|
||
- [ ] Plex library data fetched correctly
|
||
- [ ] Audnexus matching works
|
||
- [ ] Recommendations exclude library books
|
||
- [ ] Recommendations exclude already-swiped books
|
||
- [ ] Cache persistence across sessions
|
||
|
||
---
|
||
|
||
## MVP Definition
|
||
|
||
**MVP is complete when:**
|
||
|
||
✅ User can configure BookDate in setup wizard (or skip)
|
||
✅ User can configure BookDate in settings page
|
||
✅ BookDate tab visible only when configured
|
||
✅ User can view AI-generated recommendations
|
||
✅ User can swipe (mobile) or click buttons (desktop)
|
||
✅ Right swipe shows confirmation toast
|
||
✅ Request is created when user confirms
|
||
✅ Swipes are recorded and influence future recommendations
|
||
✅ Cache works (no duplicate API calls for same recommendations)
|
||
✅ Error states handled gracefully
|
||
✅ Dark mode supported
|
||
|
||
**Out of scope for MVP:**
|
||
- Advanced animations
|
||
- Swipe statistics/analytics
|
||
- Multi-AI voting
|
||
- Social features
|
||
- Advanced filtering
|
||
|
||
---
|
||
|
||
## Implementation Order
|
||
|
||
**Follow this order for fastest path to MVP:**
|
||
|
||
1. **Database schema** (Phase 1) - Foundation
|
||
2. **Config API endpoints** (Phase 2) - Test connection, save config
|
||
3. **Recommendation API endpoints** (Phase 3) - Core functionality
|
||
4. **Basic UI components** (Phase 6) - Card, loading screen
|
||
5. **BookDate page** (Phase 6) - Main interface
|
||
6. **Settings page** (Phase 5) - Configuration UI
|
||
7. **Setup wizard integration** (Phase 4) - Optional setup
|
||
8. **Plex/Audnexus integration** (Phase 7) - Real data
|
||
9. **Request integration** (Phase 7) - Complete flow
|
||
10. **Polish & testing** (Phase 8) - Bug fixes, UX improvements
|
||
|
||
---
|
||
|
||
## Key Technical Notes
|
||
|
||
### Encryption
|
||
- Use existing encryption utilities for API keys
|
||
- Never log decrypted keys
|
||
- Pattern: `encrypt()` on save, `decrypt()` on use
|
||
|
||
### Prisma
|
||
- Update `prisma/schema.prisma`
|
||
- Run `npx prisma db push` to sync
|
||
- Run `npx prisma generate` to regenerate client
|
||
- Import from `@/lib/prisma` or similar
|
||
|
||
### Auth Middleware
|
||
- Use `requireAuth(req)` for user endpoints
|
||
- Use `requireAdmin(req)` for admin endpoints
|
||
- Returns user object or null
|
||
|
||
### API Response Format
|
||
```typescript
|
||
// Success
|
||
return NextResponse.json({ success: true, data: {...} });
|
||
|
||
// Error
|
||
return NextResponse.json({ error: 'Error message' }, { status: 400 });
|
||
```
|
||
|
||
### Component Patterns
|
||
- Use `'use client'` directive for client components
|
||
- Tailwind for styling
|
||
- Dark mode: `className="bg-white dark:bg-gray-800"`
|
||
- Loading states: Show skeletons or spinners
|
||
|
||
### Mobile Swipe Library
|
||
Install `react-swipeable`:
|
||
```bash
|
||
npm install react-swipeable
|
||
```
|
||
|
||
### File Headers
|
||
Add to all new files:
|
||
```typescript
|
||
/**
|
||
* BookDate: [Brief description]
|
||
* Documentation: documentation/features/bookdate-prd.md
|
||
*/
|
||
```
|
||
|
||
---
|
||
|
||
## Documentation Updates
|
||
|
||
**After implementation, update:**
|
||
|
||
1. **TABLEOFCONTENTS.md** - Add BookDate section:
|
||
```markdown
|
||
## BookDate (AI Recommendations)
|
||
- **AI-powered recommendations, swipe interface** → features/bookdate.md
|
||
- **Configuration, setup wizard integration** → features/bookdate.md
|
||
```
|
||
|
||
2. **Create documentation/features/bookdate.md** - Token-efficient format:
|
||
```markdown
|
||
# BookDate Feature
|
||
|
||
**Status:** ✅ Implemented | AI-powered audiobook recommendations with swipe interface
|
||
|
||
## Overview
|
||
Tinder-style recommendation system using OpenAI/Claude APIs, personalized per user.
|
||
|
||
## Key Details
|
||
- **Providers:** OpenAI, Claude (Anthropic)
|
||
- **Scopes:** Full library, listened only (>25%), rated only
|
||
- **Actions:** Swipe left (reject), right (request), up (dismiss)
|
||
- **Caching:** Per-user, 10 recommendations cached
|
||
- **Context:** Max 50 books (40 library + 10 swipes)
|
||
|
||
## API Endpoints
|
||
- POST /api/bookdate/test-connection - Validate API key, fetch models
|
||
- GET/POST/DELETE /api/bookdate/config - Manage user config
|
||
- GET /api/bookdate/recommendations - Get cached or generate new
|
||
- POST /api/bookdate/swipe - Record swipe action
|
||
- POST /api/bookdate/undo - Undo last swipe
|
||
- PATCH /api/admin/bookdate/toggle - Admin enable/disable
|
||
|
||
## Database Models
|
||
- BookDateConfig (per user)
|
||
- BookDateRecommendation (cached)
|
||
- BookDateSwipe (history)
|
||
|
||
## Related: features/bookdate-prd.md (full requirements)
|
||
```
|
||
|
||
3. **Update documentation/README.md** - Add to features list:
|
||
```markdown
|
||
- BookDate: AI-powered recommendations with swipe interface
|
||
```
|
||
|
||
---
|
||
|
||
## Questions & Clarifications
|
||
|
||
If you encounter any blockers or need clarification:
|
||
|
||
1. **Check the PRD first:** `/home/user/ReadMeABook/documentation/features/bookdate-prd.md`
|
||
2. **Check relevant docs:** Use `TABLEOFCONTENTS.md` to find related documentation
|
||
3. **Follow existing patterns:** Look at similar features (setup wizard, settings pages, request flow)
|
||
4. **Ask the user:** If truly blocked, ask specific technical questions
|
||
|
||
---
|
||
|
||
## Success Criteria
|
||
|
||
**You've successfully implemented BookDate MVP when:**
|
||
|
||
- A user can complete setup wizard with BookDate configuration
|
||
- A user can view recommendations in BookDate tab
|
||
- A user can swipe through recommendations
|
||
- A user can request audiobooks from recommendations
|
||
- Recommendations improve based on swipe history
|
||
- All error states handled gracefully
|
||
- Dark mode works
|
||
- Mobile and desktop UX both functional
|
||
|
||
**Good luck! Reference the PRD frequently and follow existing code patterns.**
|