Compare commits
15 Commits
c36710d56c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ec48cd2b2 | |||
| 9b5c0ed8bb | |||
| b8652b9f0a | |||
| 1dc4d761b5 | |||
| aefe54751b | |||
| 5500965563 | |||
| 1892403554 | |||
| 1177c5b90a | |||
| 13d23d979f | |||
| 4287a74805 | |||
| 66fd575ad5 | |||
| a688945df2 | |||
| 18be9bbd55 | |||
| 1b9703b5e6 | |||
| 7e91013c3a |
40
.duckversions/.env-20251011105843.310.local
Normal file
40
.duckversions/.env-20251011105843.310.local
Normal file
@@ -0,0 +1,40 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:a3ppq@10.0.0.207:5432/biblical-guide
|
||||
DB_PASSWORD=a3ppq
|
||||
|
||||
# Build optimizations
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
# Reduce bundle analysis during builds
|
||||
DISABLE_ESLINT_PLUGIN=true
|
||||
|
||||
# Authentication
|
||||
NEXTAUTH_URL=https://biblical-guide.com
|
||||
NEXTAUTH_SECRET=development-secret-change-in-production
|
||||
JWT_SECRET=development-jwt-secret-change-in-production
|
||||
|
||||
# Azure OpenAI (Updated 2025-10-10)
|
||||
AZURE_OPENAI_KEY=42702a67a41547919877a2ab8e4837f9
|
||||
AZURE_OPENAI_ENDPOINT=https://footprints-ai.openai.azure.com
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||
AZURE_OPENAI_API_VERSION=2025-01-01-preview
|
||||
AZURE_OPENAI_EMBED_DEPLOYMENT=Text-Embedding-ada-002-V2
|
||||
AZURE_OPENAI_EMBED_API_VERSION=2023-05-15
|
||||
EMBED_DIMS=1536
|
||||
BIBLE_MD_PATH=./bibles/Biblia-Fidela-limba-romana.md
|
||||
LANG_CODE=ro
|
||||
TRANSLATION_CODE=FIDELA
|
||||
|
||||
# API Bible
|
||||
API_BIBLE_KEY=7b42606f8f809e155c9b0742c4f1849b
|
||||
|
||||
# Ollama (optional)
|
||||
OLLAMA_API_URL=http://localhost:11434
|
||||
|
||||
# WebSocket port
|
||||
WEBSOCKET_PORT=3015
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=your_stripe_secret_key_here
|
||||
STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key_here
|
||||
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret_here
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key_here
|
||||
@@ -7,6 +7,12 @@ NEXT_TELEMETRY_DISABLED=1
|
||||
# Reduce bundle analysis during builds
|
||||
DISABLE_ESLINT_PLUGIN=true
|
||||
|
||||
# Payload CMS
|
||||
PAYLOAD_SECRET=payload-development-secret-change-in-production
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3010
|
||||
PAYLOAD_PUBLIC_FRONTEND_URL=http://localhost:3010
|
||||
NEXT_PUBLIC_PAYLOAD_API_URL=http://localhost:3010/api/payload
|
||||
|
||||
# Authentication
|
||||
NEXTAUTH_URL=https://biblical-guide.com
|
||||
NEXTAUTH_SECRET=development-secret-change-in-production
|
||||
@@ -38,6 +44,7 @@ STRIPE_SECRET_KEY=sk_live_51GtAFuJN43EN3sSfcAVuTR5S3cZrgIl6wO4zQfVm7B0El8WLdsBbu
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_H0pO0dWQR0QDqLybpwlR4nDl00UhzqVGnO
|
||||
STRIPE_WEBHOOK_SECRET=whsec_9kVqP17aLh0fnU7oA7UApe2c4hKkXDYL
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_H0pO0dWQR0QDqLybpwlR4nDl00UhzqVGnO
|
||||
STRIPE_PREMIUM_PRODUCT_ID=prod_TE9c0qCn4TMgU8
|
||||
STRIPE_PREMIUM_MONTHLY_PRICE_ID=price_1SHhJDJN43EN3sSfzJ883lHA
|
||||
STRIPE_PREMIUM_YEARLY_PRICE_ID=price_1SHhKEJN43EN3sSfXYyYStNS
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_MONTHLY_PRICE_ID=price_1SHhJDJN43EN3sSfzJ883lHA
|
||||
|
||||
335
AI_CHAT_ARCHITECTURE.md
Normal file
335
AI_CHAT_ARCHITECTURE.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# AI Chat Architecture Diagram
|
||||
|
||||
## System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ BIBLICAL GUIDE - AI CHAT SYSTEM │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND LAYER │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Main Layout: /app/[locale]/layout.tsx │ │
|
||||
│ │ (FloatingChat COMMENTED OUT - Line 10 & 133) │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ FloatingChat Component (Client-side) │ │
|
||||
│ │ /components/chat/floating-chat.tsx │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ FAB Button │ │ Conversation │ │ Chat Messages │ │ │
|
||||
│ │ │ (Bottom-Right) │ │ History Sidebar │ │ Display Area │ │ │
|
||||
│ │ └─────────────────┘ └──────────────────┘ └──────────────────┘ │ │
|
||||
│ │ ▲ ▲ ▲ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ └──────────┬───────────┴────────────────────┘ │ │
|
||||
│ │ │ CustomEvents │ │
|
||||
│ │ │ floating-chat:open │ │
|
||||
│ │ │ auth:sign-in-required │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ Dispatches from: home page, Bible reader, donate page │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ fetch() requests
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ API LAYER (Backend) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ POST /api/chat (Main Chat Endpoint) │ │
|
||||
│ │ /app/api/chat/route.ts │ │
|
||||
│ │ │ │
|
||||
│ │ Input: message, conversationId, locale, history │ │
|
||||
│ │ │ │
|
||||
│ │ Processing Flow: │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ Auth │ │ Conversation │ │ Bible Verse │ │ │
|
||||
│ │ │ Check │ │ Management │ │ Search │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
|
||||
│ │ ▼ Bearer ▼ Load/Create ▼ searchBibleHybrid() │ │
|
||||
│ │ Token History (5 verses max) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Context Building: Smart History Management │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ • Always include last 6 messages │ │ │
|
||||
│ │ │ • Find relevant older messages by: │ │ │
|
||||
│ │ │ - Keyword overlap scoring │ │ │
|
||||
│ │ │ - Biblical reference detection │ │ │
|
||||
│ │ │ - Time decay (older = lower score) │ │ │
|
||||
│ │ │ • Apply token-based truncation (~1500 max) │ │ │
|
||||
│ │ │ • Summarize messages if needed │ │ │
|
||||
│ │ └────────────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Azure OpenAI REST API Call │ │ │
|
||||
│ │ │ /lib/ai/azure-openai.ts │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ POST https://footprints-ai.openai.azure.com │ │ │
|
||||
│ │ │ /openai/deployments/gpt-4o/chat/completions │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Payload: │ │ │
|
||||
│ │ │ { │ │ │
|
||||
│ │ │ messages: [ │ │ │
|
||||
│ │ │ { role: "system", content: languageSpecificSystemPrompt } │ │ │
|
||||
│ │ │ { role: "user", content: userMessage } │ │ │
|
||||
│ │ │ ], │ │ │
|
||||
│ │ │ max_tokens: 2000, │ │ │
|
||||
│ │ │ temperature: 0.7 │ │ │
|
||||
│ │ │ } │ │ │
|
||||
│ │ └────────────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Response: { success, response, conversationId } │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ GET /api/chat/conversations │ │
|
||||
│ │ List user's conversations (paginated) │ │
|
||||
│ │ Query: language, limit, offset │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ GET /api/chat/conversations/[id] │ │
|
||||
│ │ Load specific conversation with all messages │ │
|
||||
│ │ │ │
|
||||
│ │ PUT /api/chat/conversations/[id] │ │
|
||||
│ │ Rename conversation │ │
|
||||
│ │ │ │
|
||||
│ │ DELETE /api/chat/conversations/[id] │ │
|
||||
│ │ Soft delete conversation (isActive = false) │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Admin Routes (Monitoring & Management) │ │
|
||||
│ │ GET/POST /api/admin/chat/conversations │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Prisma ORM
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ DATABASE LAYER (PostgreSQL) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ChatConversation Table │ │
|
||||
│ │ │ │
|
||||
│ │ id (UUID) │ User's conversation ID │ │
|
||||
│ │ userId (UUID) │ FK to User (nullable) │ │
|
||||
│ │ title (String) │ Auto-generated from first message │ │
|
||||
│ │ language (String) │ 'ro' | 'en' | 'es' | 'it' │ │
|
||||
│ │ isActive (Boolean) │ Soft delete flag │ │
|
||||
│ │ createdAt (DateTime) │ Timestamp │ │
|
||||
│ │ updatedAt (DateTime) │ Last update │ │
|
||||
│ │ lastMessageAt (DateTime) │ For sorting recent conversations │ │
|
||||
│ │ │ │
|
||||
│ │ Indexes: │ │
|
||||
│ │ • (userId, language, lastMessageAt) │ │
|
||||
│ │ • (isActive, lastMessageAt) │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ChatMessage Table │ │
|
||||
│ │ │ │
|
||||
│ │ id (UUID) │ Message ID │ │
|
||||
│ │ conversationId (UUID) │ FK to ChatConversation (CASCADE) │ │
|
||||
│ │ userId (UUID) │ FK to User (nullable, backward compat) │ │
|
||||
│ │ role (Enum) │ USER | ASSISTANT │ │
|
||||
│ │ content (Text) │ Message content │ │
|
||||
│ │ metadata (JSON) │ Optional: verse references, etc. │ │
|
||||
│ │ timestamp (DateTime) │ When message was created │ │
|
||||
│ │ │ │
|
||||
│ │ Indexes: │ │
|
||||
│ │ • (conversationId, timestamp) │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
## Data Flow Diagram
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ User Inputs │
|
||||
│ "Ask biblical │
|
||||
│ question" │
|
||||
└────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ FloatingChat Component │
|
||||
│ • Validates input │
|
||||
│ • Shows loading state │
|
||||
│ • Adds user message to UI │
|
||||
└────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ POST /api/chat │
|
||||
│ │
|
||||
│ 1. Verify Bearer Token (Auth) │
|
||||
│ 2. Check Subscription Limits │
|
||||
│ 3. Load/Create Conversation │
|
||||
│ 4. Fetch Bible Verses (searchBibleHybrid) │
|
||||
│ 5. Build Smart Context │
|
||||
│ 6. Call Azure OpenAI API │
|
||||
│ 7. Save Messages to Database │
|
||||
│ 8. Return Response │
|
||||
└────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ Azure OpenAI (gpt-4o) │
|
||||
│ │
|
||||
│ System Prompt (Language-specific): │
|
||||
│ • "You are a Biblical AI assistant..." │
|
||||
│ • "Cite verses as [Version] Reference" │
|
||||
│ • Include Bible verses context │
|
||||
│ • Include conversation history │
|
||||
│ │
|
||||
│ User Message: │
|
||||
│ • The actual question │
|
||||
│ │
|
||||
│ Returns: │
|
||||
│ • AI-generated biblical response │
|
||||
│ • Formatted with citations │
|
||||
└────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ Response Processing │
|
||||
│ │
|
||||
│ • Extract response text │
|
||||
│ • Check for content filtering │
|
||||
│ • Handle errors gracefully │
|
||||
│ • Return formatted JSON response │
|
||||
└────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ Database Storage (Prisma) │
|
||||
│ │
|
||||
│ Save in transaction: │
|
||||
│ • ChatMessage (user message) │
|
||||
│ • ChatMessage (assistant response) │
|
||||
│ • Update ChatConversation.lastMessageAt │
|
||||
└────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ Frontend Update │
|
||||
│ │
|
||||
│ • Add assistant message to UI │
|
||||
│ • Update conversation ID if new │
|
||||
│ • Refresh conversation list │
|
||||
│ • Show typing animation → Response │
|
||||
│ • Scroll to latest message │
|
||||
└────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ User Sees │
|
||||
│ Response with │
|
||||
│ Bible References │
|
||||
└────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
## Key Integration Points
|
||||
|
||||
### 1. Authentication Flow
|
||||
```
|
||||
User logged in?
|
||||
├─ YES → Load stored token from localStorage
|
||||
│ └─ Send with every request: Authorization: Bearer <token>
|
||||
└─ NO → Show sign-in prompt
|
||||
└─ Disabled chat UI
|
||||
└─ Event: auth:sign-in-required
|
||||
```
|
||||
|
||||
### 2. Conversation Limits
|
||||
```
|
||||
Free Tier User Creates New Conversation:
|
||||
├─ Check: checkConversationLimit(userId)
|
||||
│ ├─ Reached monthly limit?
|
||||
│ │ ├─ YES → Return 403 with upgrade URL
|
||||
│ │ └─ NO → Continue
|
||||
│ └─ Increment: incrementConversationCount(userId)
|
||||
└─ Premium Users → Unlimited conversations
|
||||
```
|
||||
|
||||
### 3. Bible Verse Integration
|
||||
```
|
||||
User Message → searchBibleHybrid(message, locale, 5)
|
||||
├─ Embed message using Azure embeddings
|
||||
├─ Search pgvector database
|
||||
├─ Filter by language
|
||||
├─ Return top 5 verses with:
|
||||
│ ├─ Reference (e.g., "John 3:16")
|
||||
│ ├─ Text
|
||||
│ └─ Source table (for version info)
|
||||
└─ Include in system prompt context
|
||||
```
|
||||
|
||||
### 4. Multi-Language Support
|
||||
```
|
||||
Locale Parameter (ro | en | es | it)
|
||||
├─ System Prompt Language
|
||||
│ └─ Romanian, English, or Spanish
|
||||
├─ UI Language (via next-intl)
|
||||
│ └─ Messages from /messages/{locale}.json
|
||||
└─ Search Filtering
|
||||
└─ Only Bible versions in that language
|
||||
```
|
||||
|
||||
## Environment Dependencies
|
||||
|
||||
```
|
||||
Azure OpenAI Configuration:
|
||||
├─ AZURE_OPENAI_KEY=<key>
|
||||
├─ AZURE_OPENAI_ENDPOINT=https://footprints-ai.openai.azure.com
|
||||
├─ AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||
├─ AZURE_OPENAI_API_VERSION=2025-01-01-preview
|
||||
├─ AZURE_OPENAI_EMBED_DEPLOYMENT=Text-Embedding-ada-002-V2
|
||||
├─ AZURE_OPENAI_EMBED_API_VERSION=2023-05-15
|
||||
└─ EMBED_DIMS=1536
|
||||
|
||||
Database:
|
||||
├─ DATABASE_URL=postgresql://user:password@host:port/db
|
||||
└─ Tables created via: npm run db:migrate
|
||||
|
||||
Authentication:
|
||||
├─ JWT_SECRET=<secret>
|
||||
└─ NEXTAUTH_SECRET=<secret>
|
||||
```
|
||||
|
||||
## Enabling AI Chat
|
||||
|
||||
```
|
||||
Step 1: Edit /app/[locale]/layout.tsx
|
||||
Line 10: Uncomment: import FloatingChat from '@/components/chat/floating-chat'
|
||||
Line 133: Uncomment: <FloatingChat />
|
||||
|
||||
Step 2: Verify environment variables
|
||||
npm run dev (starts with existing .env.local)
|
||||
|
||||
Step 3: Database migration
|
||||
npm run db:migrate
|
||||
|
||||
Step 4: Test
|
||||
└─ Navigate to app
|
||||
└─ Click chat icon
|
||||
└─ Try sending a message
|
||||
```
|
||||
|
||||
386
AI_CHAT_IMPLEMENTATION_FINDINGS.md
Normal file
386
AI_CHAT_IMPLEMENTATION_FINDINGS.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# AI Chat Implementation Findings
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Biblical Guide codebase has a **fully implemented AI chat system that is currently DISABLED**. The chat functionality is present in the codebase but commented out in the main layout, preventing users from accessing it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current AI Chat Implementation Status
|
||||
|
||||
### Status: DISABLED (But Fully Functional)
|
||||
- Location of disable: `/root/biblical-guide/app/[locale]/layout.tsx` (Line 10, 133)
|
||||
- The FloatingChat component is imported but commented out with note "AI Chat disabled"
|
||||
- All API routes exist and are functional
|
||||
- Database models are in place
|
||||
- Frontend components are complete and ready
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend Components & Pages Structure
|
||||
|
||||
### Main Chat Component
|
||||
**File:** `/root/biblical-guide/components/chat/floating-chat.tsx`
|
||||
- **Type:** Client-side React component ('use client')
|
||||
- **Features:**
|
||||
- Floating action button (FAB) in bottom-right corner
|
||||
- Slide-in chat drawer with full conversation history
|
||||
- Supports fullscreen view with minimize/maximize
|
||||
- Left sidebar showing chat history with conversation management
|
||||
- Right side with message display and input
|
||||
- Multi-language support (Romanian, English, Spanish, Italian)
|
||||
- Real-time conversation persistence to database
|
||||
- Suggested questions for getting started
|
||||
- Conversation rename and delete functionality
|
||||
- Loading state with Bible-related messages
|
||||
|
||||
**State Management:**
|
||||
- Local state for messages, conversations, UI modes
|
||||
- Uses localStorage for auth token
|
||||
- Integrates with global useAuth hook
|
||||
- Conversation history loaded from `/api/chat/conversations`
|
||||
|
||||
### Secondary Chat Component
|
||||
**File:** `/root/biblical-guide/components/chat/chat-interface.tsx`
|
||||
- **Type:** Simpler alternative chat interface (Client component)
|
||||
- **Status:** Available but not currently used in main layout
|
||||
- Uses Tailwind CSS instead of Material-UI
|
||||
- Basic message display with markdown support
|
||||
|
||||
### Integration Points
|
||||
**File:** `/root/biblical-guide/app/[locale]/page.tsx`
|
||||
- Chat is triggered via custom events: `floating-chat:open`
|
||||
- Multiple buttons dispatch these events to open chat with:
|
||||
- Fullscreen mode
|
||||
- Initial message pre-populated
|
||||
- Example: "Ask AI" buttons on home page and other pages
|
||||
|
||||
---
|
||||
|
||||
## 3. API Routes & Backend Implementation
|
||||
|
||||
### Main Chat API
|
||||
**File:** `/root/biblical-guide/app/api/chat/route.ts`
|
||||
|
||||
**POST /api/chat** - Main chat endpoint
|
||||
- **Authentication:** Required (Bearer token)
|
||||
- **Request Body:**
|
||||
```json
|
||||
{
|
||||
"message": "string",
|
||||
"conversationId": "string (optional)",
|
||||
"locale": "string (optional, default: 'ro')",
|
||||
"history": "array (optional, for anonymous users)"
|
||||
}
|
||||
```
|
||||
|
||||
- **Response:**
|
||||
```json
|
||||
{
|
||||
"success": boolean,
|
||||
"response": "AI response text",
|
||||
"conversationId": "string (only if authenticated)"
|
||||
}
|
||||
```
|
||||
|
||||
- **Key Features:**
|
||||
1. **Authentication Check:** Returns 401 if no valid Bearer token
|
||||
2. **Conversation Management:**
|
||||
- Creates new conversations for authenticated users
|
||||
- Loads existing conversation history (last 15 messages)
|
||||
- Maintains conversation metadata
|
||||
3. **Subscription Limits:**
|
||||
- Free tier: Limited conversations per month
|
||||
- Premium tier: Unlimited conversations
|
||||
- Uses `/lib/subscription-utils` to check limits
|
||||
4. **Bible Vector Search:**
|
||||
- Searches for relevant Bible verses using `searchBibleHybrid()`
|
||||
- Supports language-specific filtering
|
||||
- Includes verse references in context (5 verses max)
|
||||
5. **Azure OpenAI Integration:**
|
||||
- Calls Azure OpenAI API with formatted messages
|
||||
- Supports multiple languages with specific system prompts
|
||||
- Temperature: 0.7, Max tokens: 2000
|
||||
- Handles content filtering responses
|
||||
6. **Fallback System:**
|
||||
- Language-specific fallback responses if Azure OpenAI fails
|
||||
- Gracefully degrades without blocking chat
|
||||
|
||||
### Conversation Management API
|
||||
**File:** `/root/biblical-guide/app/api/chat/conversations/route.ts`
|
||||
|
||||
**GET /api/chat/conversations** - List user conversations
|
||||
- **Query Parameters:**
|
||||
- `language` (ro|en, optional)
|
||||
- `limit` (1-50, default: 20)
|
||||
- `offset` (default: 0)
|
||||
- **Returns:** Paginated list of conversations with last message preview
|
||||
|
||||
**POST /api/chat/conversations** - Create new conversation
|
||||
- **Request:** `{ title, language }`
|
||||
- **Returns:** Created conversation object
|
||||
|
||||
### Individual Conversation API
|
||||
**File:** `/root/biblical-guide/app/api/chat/conversations/[id]/route.ts`
|
||||
- **GET:** Load specific conversation with all messages
|
||||
- **PUT:** Rename conversation
|
||||
- **DELETE:** Soft delete conversation (sets isActive to false)
|
||||
|
||||
### Admin API Routes
|
||||
**Files:**
|
||||
- `/root/biblical-guide/app/api/admin/chat/conversations/route.ts`
|
||||
- `/root/biblical-guide/app/api/admin/chat/conversations/[id]/route.ts`
|
||||
- Admin dashboard: `/root/biblical-guide/app/admin/chat/page.tsx`
|
||||
- Monitoring component: `/root/biblical-guide/components/admin/chat/conversation-monitoring.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 4. Azure OpenAI Integration
|
||||
|
||||
### Configuration
|
||||
**File:** `/root/biblical-guide/lib/ai/azure-openai.ts`
|
||||
|
||||
**Environment Variables Required:**
|
||||
```
|
||||
AZURE_OPENAI_KEY=<your-key>
|
||||
AZURE_OPENAI_ENDPOINT=https://<resource>.openai.azure.com
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||
AZURE_OPENAI_API_VERSION=2025-01-01-preview
|
||||
AZURE_OPENAI_EMBED_DEPLOYMENT=Text-Embedding-ada-002-V2
|
||||
AZURE_OPENAI_EMBED_API_VERSION=2023-05-15
|
||||
EMBED_DIMS=1536
|
||||
```
|
||||
|
||||
**Currently Configured (from .env.local):**
|
||||
```
|
||||
AZURE_OPENAI_KEY=42702a67a41547919877a2ab8e4837f9
|
||||
AZURE_OPENAI_ENDPOINT=https://footprints-ai.openai.azure.com
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||
AZURE_OPENAI_API_VERSION=2025-01-01-preview
|
||||
```
|
||||
|
||||
### AI Response Generation
|
||||
**Function:** `generateBiblicalResponse()` in `/root/biblical-guide/app/api/chat/route.ts`
|
||||
|
||||
**Process:**
|
||||
1. Uses `searchBibleHybrid()` to find relevant Bible verses with language filtering
|
||||
2. Extracts version information from database source tables
|
||||
3. Creates language-specific system prompts (Romanian, English, Spanish)
|
||||
4. Implements smart context building from conversation history:
|
||||
- Always includes last 6 messages for immediate context
|
||||
- Finds relevant older messages based on keyword/biblical reference matching
|
||||
- Applies token-based truncation (~1500 tokens max for context)
|
||||
- Can summarize older messages if needed
|
||||
5. Calls Azure OpenAI REST API with:
|
||||
- System prompt with Bible context and instructions
|
||||
- Current user message
|
||||
- Conversation history (smart context)
|
||||
6. Returns formatted response with Bible version citations
|
||||
|
||||
**System Prompts:**
|
||||
- Language-specific (Romanian, English, Spanish supported)
|
||||
- Instructs AI to cite Bible versions as "[Version] Reference"
|
||||
- Emphasizes accuracy and empathy
|
||||
- Mentions biblical passages found in context
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Models (Prisma)
|
||||
|
||||
**File:** `/root/biblical-guide/prisma/schema.prisma`
|
||||
|
||||
### ChatConversation Model
|
||||
```prisma
|
||||
model ChatConversation {
|
||||
id String @id @default(uuid())
|
||||
userId String? // Optional for anonymous users
|
||||
title String // Auto-generated from first message
|
||||
language String // 'ro' or 'en'
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastMessageAt DateTime @default(now())
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
|
||||
@@index([userId, language, lastMessageAt])
|
||||
@@index([isActive, lastMessageAt])
|
||||
}
|
||||
```
|
||||
|
||||
### ChatMessage Model
|
||||
```prisma
|
||||
model ChatMessage {
|
||||
id String @id @default(uuid())
|
||||
conversationId String
|
||||
userId String? // For backward compatibility
|
||||
role ChatMessageRole // USER | ASSISTANT
|
||||
content String @db.Text
|
||||
metadata Json? // Store verse references, etc.
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([conversationId, timestamp])
|
||||
}
|
||||
```
|
||||
|
||||
### ChatMessageRole Enum
|
||||
```prisma
|
||||
enum ChatMessageRole {
|
||||
USER
|
||||
ASSISTANT
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration & Feature Flags
|
||||
|
||||
### Current Status
|
||||
**DISABLED in code:**
|
||||
- Location: `/root/biblical-guide/app/[locale]/layout.tsx`
|
||||
- Lines 10 and 133 have the import and component commented out
|
||||
- Comment: "AI Chat disabled"
|
||||
|
||||
### To Enable Chat
|
||||
1. **Uncomment in layout.tsx:**
|
||||
```tsx
|
||||
import FloatingChat from '@/components/chat/floating-chat' // Line 10
|
||||
|
||||
// In JSX around line 133:
|
||||
<FloatingChat />
|
||||
```
|
||||
|
||||
2. **Verify environment variables are set:**
|
||||
```
|
||||
AZURE_OPENAI_KEY
|
||||
AZURE_OPENAI_ENDPOINT
|
||||
AZURE_OPENAI_DEPLOYMENT
|
||||
AZURE_OPENAI_API_VERSION
|
||||
```
|
||||
|
||||
3. **Ensure database tables exist:**
|
||||
```bash
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
### Translation Files
|
||||
**Location:** `/root/biblical-guide/messages/`
|
||||
- Chat translations exist in: `en.json`, `ro.json`, `es.json`, `it.json`
|
||||
- Key sections:
|
||||
- `chat.title` - "Biblical AI Chat"
|
||||
- `chat.subtitle` - Assistant description
|
||||
- `chat.placeholder` - Input placeholder text
|
||||
- `chat.suggestions` - Suggested questions for starting
|
||||
- `chat.enterToSend` - Keyboard hint
|
||||
|
||||
---
|
||||
|
||||
## 7. Authentication & Authorization
|
||||
|
||||
### Required for Chat
|
||||
- **Bearer Token:** Required in Authorization header
|
||||
- **Token Source:** `localStorage.getItem('authToken')`
|
||||
- **Verification:** Uses `verifyToken()` from `/lib/auth`
|
||||
|
||||
### Permission Levels
|
||||
1. **Unauthenticated:** Can see chat UI but can't send messages
|
||||
2. **Free Tier:** Limited conversations per month
|
||||
3. **Premium Tier:** Unlimited conversations
|
||||
|
||||
### Conversation Limits
|
||||
- Tracked via `/lib/subscription-utils`:
|
||||
- `checkConversationLimit(userId)` - Check if user can create new conversation
|
||||
- `incrementConversationCount(userId)` - Track monthly conversation count
|
||||
|
||||
---
|
||||
|
||||
## 8. Related Features & Dependencies
|
||||
|
||||
### Vector Search Integration
|
||||
**File:** `/root/biblical-guide/lib/vector-search.ts`
|
||||
- Function: `searchBibleHybrid(message, locale, limit)`
|
||||
- Searches Bible verses using embeddings
|
||||
- Supports language filtering
|
||||
- Returns verse objects with reference, text, and source table
|
||||
|
||||
### Event System
|
||||
- Uses custom events for chat control:
|
||||
- `floating-chat:open` - Open chat with optional params
|
||||
- `auth:sign-in-required` - Trigger auth modal from chat
|
||||
|
||||
### Subscription System
|
||||
- Tracks free vs premium users
|
||||
- Enforces conversation limits for free tier
|
||||
- Returns upgrade URL when limits reached
|
||||
|
||||
---
|
||||
|
||||
## 9. What's Needed to Enable Chat
|
||||
|
||||
### Quick Checklist
|
||||
- [ ] Uncomment FloatingChat import in `/root/biblical-guide/app/[locale]/layout.tsx` (line 10)
|
||||
- [ ] Uncomment `<FloatingChat />` component in layout JSX (line 133)
|
||||
- [ ] Verify Azure OpenAI credentials in `.env.local`
|
||||
- [ ] Verify database migration has run (tables exist)
|
||||
- [ ] Test authentication flow
|
||||
- [ ] Test with sample messages
|
||||
- [ ] Verify Bible verse search works
|
||||
- [ ] Test conversation persistence
|
||||
|
||||
### Testing
|
||||
- Test unauthenticated access (should show sign-in prompt)
|
||||
- Test authenticated chat flow
|
||||
- Test conversation history loading
|
||||
- Test conversation rename/delete
|
||||
- Test fullscreen mode
|
||||
- Test different languages
|
||||
- Test free tier limits
|
||||
- Test long conversations with context pruning
|
||||
|
||||
---
|
||||
|
||||
## 10. File Paths Summary
|
||||
|
||||
**Core Chat Implementation:**
|
||||
- `/root/biblical-guide/components/chat/floating-chat.tsx` - Main UI component
|
||||
- `/root/biblical-guide/components/chat/chat-interface.tsx` - Alternative UI
|
||||
- `/root/biblical-guide/app/api/chat/route.ts` - Main API endpoint
|
||||
- `/root/biblical-guide/app/api/chat/conversations/route.ts` - Conversation list/create
|
||||
- `/root/biblical-guide/app/api/chat/conversations/[id]/route.ts` - Individual conversation
|
||||
|
||||
**Configuration:**
|
||||
- `/root/biblical-guide/.env.local` - Azure OpenAI keys
|
||||
- `/root/biblical-guide/lib/ai/azure-openai.ts` - Azure integration
|
||||
- `/root/biblical-guide/prisma/schema.prisma` - Database models
|
||||
- `/root/biblical-guide/messages/en.json` - English translations
|
||||
|
||||
**Admin:**
|
||||
- `/root/biblical-guide/app/admin/chat/page.tsx` - Admin dashboard
|
||||
- `/root/biblical-guide/components/admin/chat/conversation-monitoring.tsx` - Monitoring UI
|
||||
- `/root/biblical-guide/app/api/admin/chat/conversations/route.ts` - Admin API
|
||||
|
||||
**Layout/Integration:**
|
||||
- `/root/biblical-guide/app/[locale]/layout.tsx` - Main layout (chat disabled here)
|
||||
- `/root/biblical-guide/app/[locale]/page.tsx` - Home page (has chat triggers)
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**The AI chat is fully implemented and ready to use.** It's only disabled by being commented out in the layout file. The system includes:
|
||||
|
||||
1. Complete frontend UI with Material-UI components
|
||||
2. Full backend API with conversation persistence
|
||||
3. Azure OpenAI integration for intelligent responses
|
||||
4. Bible verse search and context injection
|
||||
5. Multi-language support
|
||||
6. Subscription tier enforcement
|
||||
7. Admin monitoring capabilities
|
||||
8. Conversation management (create, rename, delete)
|
||||
|
||||
To enable it, simply uncomment 2 lines in the main layout file.
|
||||
|
||||
713
AI_SMART_SUGGESTIONS_PLAN.md
Normal file
713
AI_SMART_SUGGESTIONS_PLAN.md
Normal file
@@ -0,0 +1,713 @@
|
||||
# AI-Powered Smart Suggestions - Implementation Plan
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement AI-powered features that provide intelligent suggestions, thematic discovery, semantic search, and personalized recommendations to enhance Bible study and deepen Scripture understanding.
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🔵 Future
|
||||
**Estimated Time:** 4-6 weeks (160-240 hours)
|
||||
**Target Completion:** TBD
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals & Objectives
|
||||
|
||||
### Primary Goals
|
||||
1. Provide AI-powered verse recommendations
|
||||
2. Enable semantic (meaning-based) search
|
||||
3. Generate study questions automatically
|
||||
4. Discover thematic connections
|
||||
5. Personalize user experience with ML
|
||||
|
||||
### User Value Proposition
|
||||
- **For students**: Discover related content automatically
|
||||
- **For scholars**: Find thematic patterns
|
||||
- **For personal study**: Get personalized recommendations
|
||||
- **For teachers**: Generate discussion questions
|
||||
- **For explorers**: Uncover hidden connections
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Specifications
|
||||
|
||||
### 1. AI Architecture
|
||||
|
||||
```typescript
|
||||
interface AIConfig {
|
||||
// Providers
|
||||
provider: 'openai' | 'azure' | 'ollama' | 'anthropic'
|
||||
model: string // gpt-4, gpt-3.5-turbo, claude-3, llama2, etc.
|
||||
apiKey?: string
|
||||
endpoint?: string
|
||||
|
||||
// Features
|
||||
enableSuggestions: boolean
|
||||
enableSemanticSearch: boolean
|
||||
enableQuestionGeneration: boolean
|
||||
enableSummarization: boolean
|
||||
enableThematicAnalysis: boolean
|
||||
|
||||
// Behavior
|
||||
cacheResponses: boolean
|
||||
maxTokens: number
|
||||
temperature: number // 0-1, creativity
|
||||
enableRAG: boolean // Retrieval Augmented Generation
|
||||
}
|
||||
|
||||
interface AIService {
|
||||
// Core methods
|
||||
generateSuggestions(verse: VerseReference): Promise<Suggestion[]>
|
||||
semanticSearch(query: string): Promise<SearchResult[]>
|
||||
generateQuestions(passage: string): Promise<Question[]>
|
||||
summarizeChapter(book: string, chapter: number): Promise<string>
|
||||
analyzeThemes(verses: string[]): Promise<Theme[]>
|
||||
explainVerse(verse: string): Promise<Explanation>
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Smart Verse Suggestions
|
||||
|
||||
```typescript
|
||||
interface Suggestion {
|
||||
id: string
|
||||
type: 'related' | 'thematic' | 'contextual' | 'application' | 'cross-ref'
|
||||
verse: VerseReference
|
||||
reason: string // Why this was suggested
|
||||
relevanceScore: number // 0-1
|
||||
metadata?: {
|
||||
theme?: string
|
||||
category?: string
|
||||
connection?: string
|
||||
}
|
||||
}
|
||||
|
||||
const SmartSuggestions: React.FC<{
|
||||
currentVerse: VerseReference
|
||||
}> = ({ currentVerse }) => {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadSuggestions()
|
||||
}, [currentVerse])
|
||||
|
||||
const loadSuggestions = async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
verse: currentVerse,
|
||||
limit: 10
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
setSuggestions(data.suggestions)
|
||||
} catch (error) {
|
||||
console.error('Failed to load suggestions:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="AI Suggestions"
|
||||
avatar={<AutoAwesomeIcon />}
|
||||
action={
|
||||
<IconButton onClick={loadSuggestions} disabled={loading}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={3}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : suggestions.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
No suggestions available for this verse.
|
||||
</Alert>
|
||||
) : (
|
||||
<List>
|
||||
{suggestions.map(suggestion => (
|
||||
<ListItem key={suggestion.id} divider>
|
||||
<ListItemIcon>
|
||||
{getIconForType(suggestion.type)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={formatVerseReference(suggestion.verse)}
|
||||
secondary={suggestion.reason}
|
||||
/>
|
||||
<Chip
|
||||
label={`${Math.round(suggestion.relevanceScore * 100)}%`}
|
||||
size="small"
|
||||
color={suggestion.relevanceScore > 0.7 ? 'success' : 'default'}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Semantic Search with Vector Embeddings
|
||||
|
||||
```typescript
|
||||
// Generate embeddings for Bible verses
|
||||
const generateEmbedding = async (text: string): Promise<number[]> => {
|
||||
const response = await fetch('/api/ai/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return data.embedding
|
||||
}
|
||||
|
||||
// Semantic search implementation
|
||||
const semanticSearch = async (query: string): Promise<SearchResult[]> => {
|
||||
// Generate embedding for query
|
||||
const queryEmbedding = await generateEmbedding(query)
|
||||
|
||||
// Find similar verses using vector similarity
|
||||
const results = await prisma.$queryRaw`
|
||||
SELECT
|
||||
v."id",
|
||||
v."book",
|
||||
v."chapter",
|
||||
v."verseNum",
|
||||
v."text",
|
||||
1 - (v."embedding" <=> ${queryEmbedding}::vector) AS similarity
|
||||
FROM "BibleVerse" v
|
||||
WHERE v."embedding" IS NOT NULL
|
||||
ORDER BY v."embedding" <=> ${queryEmbedding}::vector
|
||||
LIMIT 20
|
||||
`
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
const SemanticSearch: React.FC = () => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) return
|
||||
|
||||
setSearching(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/search/semantic', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
setResults(data.results)
|
||||
} catch (error) {
|
||||
console.error('Semantic search failed:', error)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Semantic Search
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Search by meaning, not just keywords. Ask questions like "verses about hope" or "God's love for humanity"
|
||||
</Alert>
|
||||
|
||||
<Box display="flex" gap={1} mb={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="What are you looking for? (e.g., 'overcoming fear')"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSearch}
|
||||
disabled={searching}
|
||||
startIcon={searching ? <CircularProgress size={20} /> : <SearchIcon />}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Results */}
|
||||
{results.map(result => (
|
||||
<Card key={result.id} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="start">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{result.book} {result.chapter}:{result.verseNum}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{result.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${Math.round(result.similarity * 100)}% match`}
|
||||
size="small"
|
||||
color={result.similarity > 0.8 ? 'success' : 'default'}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. AI Study Question Generator
|
||||
|
||||
```typescript
|
||||
interface Question {
|
||||
id: string
|
||||
type: 'comprehension' | 'application' | 'reflection' | 'analysis' | 'discussion'
|
||||
question: string
|
||||
difficulty: 'easy' | 'medium' | 'hard'
|
||||
suggestedAnswer?: string
|
||||
}
|
||||
|
||||
const generateStudyQuestions = async (
|
||||
passage: string,
|
||||
count: number = 5
|
||||
): Promise<Question[]> => {
|
||||
const prompt = `
|
||||
Generate ${count} thoughtful study questions for the following Bible passage.
|
||||
Include a mix of comprehension, application, and reflection questions.
|
||||
|
||||
Passage:
|
||||
${passage}
|
||||
|
||||
Return as JSON array with format:
|
||||
[
|
||||
{
|
||||
"type": "comprehension|application|reflection|analysis|discussion",
|
||||
"question": "the question",
|
||||
"difficulty": "easy|medium|hard"
|
||||
}
|
||||
]
|
||||
`
|
||||
|
||||
const response = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
temperature: 0.7,
|
||||
maxTokens: 1000
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return JSON.parse(data.response)
|
||||
}
|
||||
|
||||
const StudyQuestionGenerator: React.FC<{
|
||||
passage: string
|
||||
}> = ({ passage }) => {
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
|
||||
try {
|
||||
const generated = await generateStudyQuestions(passage)
|
||||
setQuestions(generated)
|
||||
} catch (error) {
|
||||
console.error('Failed to generate questions:', error)
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">
|
||||
Study Questions
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
startIcon={generating ? <CircularProgress size={20} /> : <AutoAwesomeIcon />}
|
||||
>
|
||||
Generate Questions
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{questions.length > 0 && (
|
||||
<List>
|
||||
{questions.map((question, index) => (
|
||||
<Card key={index} sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" gap={1} mb={1}>
|
||||
<Chip label={question.type} size="small" />
|
||||
<Chip
|
||||
label={question.difficulty}
|
||||
size="small"
|
||||
color={
|
||||
question.difficulty === 'easy' ? 'success' :
|
||||
question.difficulty === 'medium' ? 'warning' : 'error'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body1" fontWeight="500">
|
||||
{index + 1}. {question.question}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Thematic Analysis
|
||||
|
||||
```typescript
|
||||
interface Theme {
|
||||
name: string
|
||||
description: string
|
||||
verses: VerseReference[]
|
||||
relevance: number // 0-1
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
const analyzeThemes = async (verses: string[]): Promise<Theme[]> => {
|
||||
const prompt = `
|
||||
Analyze the following Bible verses and identify the main themes, topics, and theological concepts.
|
||||
For each theme, provide:
|
||||
- Name
|
||||
- Description
|
||||
- Keywords
|
||||
- Relevance score (0-1)
|
||||
|
||||
Verses:
|
||||
${verses.join('\n\n')}
|
||||
|
||||
Return as JSON array.
|
||||
`
|
||||
|
||||
const response = await fetch('/api/ai/analyze/themes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, verses })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return data.themes
|
||||
}
|
||||
|
||||
const ThematicAnalysis: React.FC<{
|
||||
book: string
|
||||
chapter: number
|
||||
}> = ({ book, chapter }) => {
|
||||
const [themes, setThemes] = useState<Theme[]>([])
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
performAnalysis()
|
||||
}, [book, chapter])
|
||||
|
||||
const performAnalysis = async () => {
|
||||
setAnalyzing(true)
|
||||
|
||||
try {
|
||||
// Fetch chapter verses
|
||||
const verses = await fetchChapterVerses(book, chapter)
|
||||
|
||||
// Analyze themes
|
||||
const themes = await analyzeThemes(verses.map(v => v.text))
|
||||
setThemes(themes)
|
||||
} catch (error) {
|
||||
console.error('Theme analysis failed:', error)
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Thematic Analysis
|
||||
</Typography>
|
||||
|
||||
{analyzing ? (
|
||||
<Box display="flex" justifyContent="center" p={3}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{themes.map((theme, index) => (
|
||||
<Grid item xs={12} sm={6} key={index}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{theme.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
{theme.description}
|
||||
</Typography>
|
||||
<Box display="flex" gap={0.5} flexWrap="wrap" mb={1}>
|
||||
{theme.keywords.map(keyword => (
|
||||
<Chip key={keyword} label={keyword} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={theme.relevance * 100}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Relevance: {Math.round(theme.relevance * 100)}%
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. RAG (Retrieval Augmented Generation)
|
||||
|
||||
```typescript
|
||||
// RAG implementation for contextual AI responses
|
||||
const ragQuery = async (question: string, context: string[]): Promise<string> => {
|
||||
// Step 1: Find relevant verses using semantic search
|
||||
const relevantVerses = await semanticSearch(question)
|
||||
|
||||
// Step 2: Build context from retrieved verses
|
||||
const contextText = relevantVerses
|
||||
.slice(0, 5)
|
||||
.map(v => `${v.book} ${v.chapter}:${v.verseNum} - ${v.text}`)
|
||||
.join('\n\n')
|
||||
|
||||
// Step 3: Generate response with context
|
||||
const prompt = `
|
||||
You are a Bible study assistant. Answer the following question using ONLY the provided Scripture context.
|
||||
Be accurate and cite specific verses.
|
||||
|
||||
Context:
|
||||
${contextText}
|
||||
|
||||
Question: ${question}
|
||||
|
||||
Answer:
|
||||
`
|
||||
|
||||
const response = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
temperature: 0.3, // Lower temperature for accuracy
|
||||
maxTokens: 500
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return data.response
|
||||
}
|
||||
|
||||
const RAGChatbot: React.FC = () => {
|
||||
const [messages, setMessages] = useState<Array<{ role: 'user' | 'assistant', content: string }>>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [thinking, setThinking] = useState(false)
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim()) return
|
||||
|
||||
const userMessage = { role: 'user' as const, content: input }
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInput('')
|
||||
setThinking(true)
|
||||
|
||||
try {
|
||||
const answer = await ragQuery(input, [])
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: answer }])
|
||||
} catch (error) {
|
||||
console.error('RAG query failed:', error)
|
||||
} finally {
|
||||
setThinking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Ask the Bible
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ height: 400, overflow: 'auto', p: 2, mb: 2 }}>
|
||||
{messages.map((msg, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start'
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
maxWidth: '70%',
|
||||
bgcolor: msg.role === 'user' ? 'primary.main' : 'grey.200',
|
||||
color: msg.role === 'user' ? 'white' : 'text.primary'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">{msg.content}</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{thinking && (
|
||||
<Box display="flex" gap={1} alignItems="center">
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="caption">Thinking...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Box display="flex" gap={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Ask a question about the Bible..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
|
||||
/>
|
||||
<Button variant="contained" onClick={handleSend} disabled={thinking}>
|
||||
Send
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
```prisma
|
||||
model BibleVerse {
|
||||
// ... existing fields
|
||||
embedding Float[]? @db.Vector(1536) // For semantic search
|
||||
embeddedAt DateTime?
|
||||
}
|
||||
|
||||
model AISuggestion {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
sourceVerse String // book:chapter:verse
|
||||
targetVerse String
|
||||
type String // related, thematic, contextual, etc.
|
||||
reason String
|
||||
relevance Float
|
||||
|
||||
clicked Boolean @default(false)
|
||||
helpful Boolean?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, sourceVerse])
|
||||
}
|
||||
|
||||
model AICache {
|
||||
id String @id @default(cuid())
|
||||
query String @unique
|
||||
response Json
|
||||
provider String
|
||||
model String
|
||||
tokens Int
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
@@index([query])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Timeline
|
||||
|
||||
### Phase 1: Foundation (Week 1-2)
|
||||
- [ ] Set up AI provider integration
|
||||
- [ ] Implement vector embeddings
|
||||
- [ ] Build semantic search
|
||||
- [ ] Create caching layer
|
||||
|
||||
### Phase 2: Features (Week 3-4)
|
||||
- [ ] Smart suggestions engine
|
||||
- [ ] Question generator
|
||||
- [ ] Thematic analysis
|
||||
- [ ] RAG chatbot
|
||||
|
||||
### Phase 3: Optimization (Week 5-6)
|
||||
- [ ] Performance tuning
|
||||
- [ ] Cost optimization
|
||||
- [ ] A/B testing
|
||||
- [ ] User feedback loop
|
||||
|
||||
---
|
||||
|
||||
## 💰 Cost Considerations
|
||||
|
||||
### OpenAI Pricing (estimated)
|
||||
- GPT-4: $0.03/1K input tokens, $0.06/1K output
|
||||
- GPT-3.5-turbo: $0.0005/1K tokens
|
||||
- Embeddings: $0.0001/1K tokens
|
||||
|
||||
### Monthly estimates for 10,000 active users:
|
||||
- Embeddings (one-time): ~$50
|
||||
- Suggestions (10/user/month): ~$150
|
||||
- Semantic search (50/user/month): ~$25
|
||||
- Questions (5/user/month): ~$200
|
||||
- **Total**: ~$425/month
|
||||
|
||||
### Cost Optimization:
|
||||
- Cache all responses (reduce by 60%)
|
||||
- Use GPT-3.5 where possible
|
||||
- Rate limiting per user
|
||||
- Consider self-hosted Ollama for basic tasks
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Status:** Ready for Implementation
|
||||
890
BACKEND_ARCHITECTURE_ANALYSIS.md
Normal file
890
BACKEND_ARCHITECTURE_ANALYSIS.md
Normal file
@@ -0,0 +1,890 @@
|
||||
# Biblical Guide - Backend Architecture Analysis
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Biblical Guide application is a comprehensive Next.js-based web application with a sophisticated backend architecture designed to support Bible reading, prayer requests, AI chat, user management, and a full admin panel. The backend utilizes PostgreSQL for persistent storage, JWT for authentication, Stripe for payments/subscriptions, and various third-party integrations.
|
||||
|
||||
---
|
||||
|
||||
## 1. DATABASE SCHEMA & MODELS
|
||||
|
||||
### Database Provider
|
||||
- **Type**: PostgreSQL
|
||||
- **ORM**: Prisma 6.16.2
|
||||
- **Connection**: Via `DATABASE_URL` environment variable
|
||||
- **Total Models**: 32 core data models
|
||||
|
||||
### Core Data Models
|
||||
|
||||
#### User Management
|
||||
|
||||
**User**
|
||||
- Unique fields: `id`, `email`
|
||||
- Authentication: `passwordHash`
|
||||
- Profile: `name`, `role` ("user", "admin", "moderator"), `theme`, `fontSize`, `favoriteBibleVersion`
|
||||
- Subscription: `subscriptionTier` ("free", "premium"), `subscriptionStatus`, `conversationLimit`, `conversationCount`, `limitResetDate`
|
||||
- Stripe Integration: `stripeCustomerId`, `stripeSubscriptionId`
|
||||
- Tracking: `createdAt`, `updatedAt`, `lastLoginAt`
|
||||
- Relationships: 14 one-to-many relationships (sessions, bookmarks, notes, highlights, etc.)
|
||||
- Indexes: on `role`, `subscriptionTier`, `stripeCustomerId`
|
||||
|
||||
**Session**
|
||||
- Fields: `id`, `userId`, `token` (unique), `expiresAt`, `createdAt`
|
||||
- 7-day token expiration
|
||||
- Cascade delete when user deleted
|
||||
- Indexes: on `userId`, `token`
|
||||
|
||||
#### Bible Data Models
|
||||
|
||||
**BibleVersion**
|
||||
- Fields: `id`, `name`, `abbreviation`, `language`, `description`, `country`, `englishTitle`, `flagImageUrl`, `zipFileUrl`, `isDefault`
|
||||
- Composite unique constraint: `abbreviation` + `language`
|
||||
- Supports multi-language Bible versions
|
||||
- Indexes: on `language`, `isDefault`, and composite index `(language, isDefault)`
|
||||
|
||||
**BibleBook**
|
||||
- Fields: `id`, `versionId`, `name`, `testament`, `orderNum`, `bookKey` (cross-version matching)
|
||||
- Links to `BibleVersion`
|
||||
- Unique constraints: `(versionId, orderNum)` and `(versionId, bookKey)`
|
||||
- Indexes: on `versionId`, `testament`
|
||||
|
||||
**BibleChapter**
|
||||
- Fields: `id`, `bookId`, `chapterNum`
|
||||
- Unique constraint: `(bookId, chapterNum)`
|
||||
- Index: on `bookId`
|
||||
|
||||
**BibleVerse**
|
||||
- Fields: `id`, `chapterId`, `verseNum`, `text` (Text type)
|
||||
- Unique constraint: `(chapterId, verseNum)`
|
||||
- Index: on `chapterId`
|
||||
|
||||
**BiblePassage** (Legacy/Embedding Search)
|
||||
- Fields: `id`, `testament`, `book`, `chapter`, `verse`, `ref`, `lang`, `translation`, `textRaw`, `textNorm`, `embedding` (vector)
|
||||
- Used for AI embedding search functionality
|
||||
- Unique constraint: `(translation, lang, book, chapter, verse)`
|
||||
- Indexes: on `(book, chapter)`, `testament`
|
||||
|
||||
#### User Content Models
|
||||
|
||||
**Bookmark** (Verse-level)
|
||||
- Fields: `id`, `userId`, `verseId`, `note`, `color` (#FFD700 default), `createdAt`
|
||||
- Unique constraint: `(userId, verseId)` - one bookmark per verse per user
|
||||
- Indexes: on `userId`
|
||||
|
||||
**ChapterBookmark**
|
||||
- Fields: `id`, `userId`, `bookId`, `chapterNum`, `note`, `createdAt`
|
||||
- Unique constraint: `(userId, bookId, chapterNum)`
|
||||
- Index: on `userId`
|
||||
|
||||
**Highlight**
|
||||
- Fields: `id`, `userId`, `verseId`, `color`, `note` (Text), `tags[]`, `createdAt`, `updatedAt`
|
||||
- Supports colored highlighting with notes and tags
|
||||
- Unique constraint: `(userId, verseId)`
|
||||
- Indexes: on `userId`, `verseId`
|
||||
|
||||
**Note**
|
||||
- Fields: `id`, `userId`, `verseId`, `content` (Text), `createdAt`, `updatedAt`
|
||||
- User notes on verses
|
||||
- Indexes: on `userId`, `verseId`
|
||||
|
||||
**ReadingHistory**
|
||||
- Fields: `id`, `userId`, `versionId`, `bookId`, `chapterNum`, `verseNum`, `viewedAt`
|
||||
- Tracks user reading position
|
||||
- Unique constraint: `(userId, versionId)` - one reading position per version per user
|
||||
- Indexes: on `(userId, viewedAt)`, `(userId, versionId)`
|
||||
|
||||
#### Communication Models
|
||||
|
||||
**ChatConversation**
|
||||
- Fields: `id`, `userId` (optional for anonymous), `title` (auto-generated), `language` ("ro"/"en"), `isActive`, `createdAt`, `updatedAt`, `lastMessageAt`
|
||||
- Supports authenticated and anonymous conversations
|
||||
- Cascade delete on user delete
|
||||
- Index: composite `(userId, language, lastMessageAt)`
|
||||
|
||||
**ChatMessage**
|
||||
- Fields: `id`, `conversationId`, `userId` (optional), `role` (USER/ASSISTANT/SYSTEM), `content` (Text), `metadata` (JSON), `timestamp`
|
||||
- Cascade delete on conversation/user delete
|
||||
- Indexes: on `(conversationId, timestamp)`, `(userId, timestamp)`
|
||||
|
||||
**ChatMessageRole Enum**
|
||||
- Values: `USER`, `ASSISTANT`, `SYSTEM`
|
||||
|
||||
#### Prayer System Models
|
||||
|
||||
**PrayerRequest**
|
||||
- Fields: `id`, `userId` (optional), `title`, `description` (Text), `category` (personal/family/health/work/ministry/world), `author`, `isAnonymous`, `isPublic`, `language`, `prayerCount`, `isActive`, `createdAt`, `updatedAt`
|
||||
- Supports public/private and anonymous prayers
|
||||
- Cascade delete on user delete
|
||||
- Indexes: on `createdAt`, `category`, `isActive`
|
||||
|
||||
**Prayer**
|
||||
- Fields: `id`, `requestId`, `ipAddress`, `createdAt`
|
||||
- Anonymous prayer tracking via IP address
|
||||
- Unique constraint: `(requestId, ipAddress)` - one prayer per IP per request
|
||||
|
||||
**UserPrayer**
|
||||
- Fields: `id`, `userId`, `requestId`, `createdAt`
|
||||
- Authenticated user prayer tracking
|
||||
- Unique constraint: `(userId, requestId)`
|
||||
- Indexes: on `userId`, `requestId`
|
||||
|
||||
#### Reading Plans Models
|
||||
|
||||
**ReadingPlan**
|
||||
- Fields: `id`, `name`, `description`, `type` (PREDEFINED/CUSTOM), `duration` (days), `schedule` (JSON), `difficulty`, `language`, `isActive`, `createdAt`, `updatedAt`
|
||||
- Flexible schedule format supporting multiple languages
|
||||
- Indexes: on `type`, `language`, `isActive`
|
||||
|
||||
**UserReadingPlan**
|
||||
- Fields: `id`, `userId`, `planId` (optional for custom), `name`, `startDate`, `targetEndDate`, `actualEndDate`, `status` (ACTIVE/COMPLETED/PAUSED/CANCELLED), `currentDay`, `completedDays`, `streak`, `longestStreak`, `customSchedule` (JSON), `reminderEnabled`, `reminderTime`, `createdAt`, `updatedAt`
|
||||
- Tracks user progress in reading plans with streaks
|
||||
- Indexes: on `userId`, `status`, `(userId, status)`
|
||||
|
||||
**UserReadingProgress**
|
||||
- Fields: `id`, `userId`, `userPlanId`, `planDay`, `date`, `bookId`, `chapterNum`, `versesRead`, `completed`, `notes` (Text), `createdAt`, `updatedAt`
|
||||
- Unique constraint: `(userPlanId, planDay, bookId, chapterNum)` - one entry per chapter per day per plan
|
||||
- Indexes: on `userId`, `userPlanId`, `(userId, date)`
|
||||
|
||||
#### Payment & Subscription Models
|
||||
|
||||
**Donation**
|
||||
- Fields: `id`, `userId` (optional), `stripeSessionId` (unique), `stripePaymentId`, `email`, `name`, `amount` (cents), `currency` ("usd" default), `status` (PENDING/COMPLETED/FAILED/REFUNDED/CANCELLED), `message` (Text), `isAnonymous`, `isRecurring`, `recurringInterval`, `metadata` (JSON), `createdAt`, `updatedAt`
|
||||
- Supports one-time and recurring donations
|
||||
- Set null on user delete (anonymous donations preserved)
|
||||
- Indexes: on `userId`, `status`, `createdAt`, `email`
|
||||
|
||||
**Subscription**
|
||||
- Fields: `id`, `userId`, `stripeSubscriptionId` (unique), `stripePriceId`, `stripeCustomerId`, `status` (SubscriptionStatus enum), `currentPeriodStart`, `currentPeriodEnd`, `cancelAtPeriodEnd`, `tier` ("premium"), `interval` ("month"/"year"), `metadata` (JSON), `createdAt`, `updatedAt`
|
||||
- Tracks active Stripe subscriptions
|
||||
- Cascade delete on user delete
|
||||
- Indexes: on `userId`, `status`, `stripeSubscriptionId`
|
||||
|
||||
**SubscriptionStatus Enum**
|
||||
- Values: `ACTIVE`, `CANCELLED`, `PAST_DUE`, `TRIALING`, `INCOMPLETE`, `INCOMPLETE_EXPIRED`, `UNPAID`
|
||||
|
||||
#### Content Management Models
|
||||
|
||||
**Page**
|
||||
- Fields: `id`, `title`, `slug` (unique), `content` (Text), `contentType` (RICH_TEXT/HTML/MARKDOWN), `excerpt`, `featuredImage`, `seoTitle`, `seoDescription`, `status` (DRAFT/PUBLISHED/ARCHIVED), `showInNavigation`, `showInFooter`, `navigationOrder`, `footerOrder`, `createdBy`, `updatedBy`, `createdAt`, `updatedAt`, `publishedAt`
|
||||
- Full CMS functionality with SEO support
|
||||
- Indexes: on `slug`, `status`, `(showInNavigation, navigationOrder)`, `(showInFooter, footerOrder)`
|
||||
|
||||
**MediaFile**
|
||||
- Fields: `id`, `filename`, `originalName`, `mimeType`, `size`, `path`, `url`, `alt`, `uploadedBy`, `createdAt`
|
||||
- File storage tracking
|
||||
- Indexes: on `uploadedBy`, `mimeType`
|
||||
|
||||
**SocialMediaLink**
|
||||
- Fields: `id`, `platform` (unique), `name`, `url`, `icon`, `isEnabled`, `order`, `createdBy`, `updatedBy`, `createdAt`, `updatedAt`
|
||||
- Manages social media links in footer
|
||||
- Index: on `(isEnabled, order)`
|
||||
|
||||
**MailgunSettings**
|
||||
- Fields: `id`, `apiKey` (encrypted), `domain`, `region` ("US"/"EU"), `fromEmail`, `fromName`, `replyToEmail`, `isEnabled`, `testMode`, `webhookUrl`, `updatedBy`, `createdAt`, `updatedAt`
|
||||
- Email service configuration
|
||||
- Index: on `isEnabled`
|
||||
|
||||
#### User Preferences
|
||||
|
||||
**UserPreference**
|
||||
- Fields: `id`, `userId`, `key`, `value`
|
||||
- Key-value store for user settings
|
||||
- Unique constraint: `(userId, key)`
|
||||
|
||||
---
|
||||
|
||||
## 2. AUTHENTICATION SYSTEM
|
||||
|
||||
### JWT-Based Authentication
|
||||
|
||||
**Token Architecture**
|
||||
- **Algorithm**: JWT with HS256
|
||||
- **Secret**: Stored in `JWT_SECRET` environment variable
|
||||
- **Expiration**: 7 days for user tokens, 24 hours for admin tokens
|
||||
- **Payload**: `{ userId: string }` for users, `{ userId, email, role, type: 'admin' }` for admins
|
||||
|
||||
**Authentication Flow**
|
||||
|
||||
1. **User Registration** (`POST /api/auth/register`)
|
||||
- Validates email/password with Zod schemas
|
||||
- Creates `User` with hashed password (bcryptjs, 10 rounds)
|
||||
- Generates JWT token
|
||||
- Creates `Session` record with 7-day expiration
|
||||
- Returns user data and token
|
||||
|
||||
2. **User Login** (`POST /api/auth/login`)
|
||||
- Validates credentials against stored hash
|
||||
- Generates JWT token
|
||||
- Creates `Session` record
|
||||
- Updates `lastLoginAt`
|
||||
- Returns user data and token
|
||||
|
||||
3. **Token Verification**
|
||||
- `verifyToken(token)`: Verifies JWT signature and returns decoded payload
|
||||
- `getUserFromToken(token)`: Retrieves full user record from token
|
||||
- `isTokenExpired(token)`: Checks expiration without verification (client-side safe)
|
||||
|
||||
4. **Admin Authentication** (`POST /api/admin/auth/login`)
|
||||
- Requires `role` to be "admin" or "moderator"
|
||||
- Returns admin token via secure httpOnly cookie
|
||||
- Cookie: `adminToken`, httpOnly, secure (production), sameSite: strict, max age 8 hours
|
||||
- Also accepts Bearer token in Authorization header
|
||||
|
||||
### Admin Permission System
|
||||
|
||||
**Admin Roles**
|
||||
- **Admin**: Full system access (super admin)
|
||||
- **Moderator**: Limited access (content, user management, analytics)
|
||||
|
||||
**Permission Enums** (from `lib/admin-auth.ts`)
|
||||
```
|
||||
READ_USERS
|
||||
WRITE_USERS
|
||||
DELETE_USERS
|
||||
READ_CONTENT
|
||||
WRITE_CONTENT
|
||||
DELETE_CONTENT
|
||||
READ_ANALYTICS
|
||||
READ_CHAT
|
||||
WRITE_CHAT
|
||||
DELETE_CHAT
|
||||
SYSTEM_BACKUP
|
||||
SYSTEM_HEALTH
|
||||
SUPER_ADMIN
|
||||
```
|
||||
|
||||
**Moderator Permissions**: READ_USERS, WRITE_USERS, READ_CONTENT, WRITE_CONTENT, DELETE_CONTENT, READ_ANALYTICS, READ_CHAT, WRITE_CHAT
|
||||
|
||||
**Auth Middleware**
|
||||
- `verifyAdminAuth()`: Checks Bearer token or adminToken cookie
|
||||
- `hasAdminAccess()`: Validates admin/moderator role
|
||||
- `isSuperAdmin()`: Checks admin role specifically
|
||||
- `hasPermission()`: Validates specific permission
|
||||
|
||||
### Client-Side Auth Management
|
||||
|
||||
**Token Handling**
|
||||
- Tokens stored in `localStorage` as `authToken`
|
||||
- Client function: `isTokenExpired()` - decodes JWT without verification
|
||||
- Client function: `clearExpiredToken()` - removes expired tokens from storage
|
||||
|
||||
**Authentication Headers**
|
||||
- Format: `Authorization: Bearer <token>`
|
||||
- Used in all protected API endpoints
|
||||
|
||||
---
|
||||
|
||||
## 3. PAYMENT & SUBSCRIPTION SYSTEM
|
||||
|
||||
### Stripe Integration
|
||||
|
||||
**Configuration**
|
||||
- **Client Library**: `@stripe/stripe-js` 8.0.0
|
||||
- **Server Library**: `stripe` 19.1.0
|
||||
- **API Version**: `2025-09-30.clover`
|
||||
- **Keys**:
|
||||
- Public Key: `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
|
||||
- Secret Key: `STRIPE_SECRET_KEY`
|
||||
- Webhook Secret: `STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
**Subscription Pricing**
|
||||
- **Free Tier**: 10 conversations/month limit
|
||||
- **Premium Tier**: Unlimited conversations
|
||||
- **Price IDs**:
|
||||
- `STRIPE_PREMIUM_MONTHLY_PRICE_ID`
|
||||
- `STRIPE_PREMIUM_YEARLY_PRICE_ID`
|
||||
|
||||
### Donation System
|
||||
|
||||
**Donation Flow** (`POST /api/stripe/checkout`)
|
||||
- Creates Stripe checkout session
|
||||
- Supports one-time and recurring donations
|
||||
- Presets: $5, $10, $25, $50, $100, $250
|
||||
- Tracks via `Donation` model with statuses:
|
||||
- PENDING (initial)
|
||||
- COMPLETED (payment succeeded)
|
||||
- FAILED (payment failed)
|
||||
- REFUNDED (refunded)
|
||||
- CANCELLED (session expired)
|
||||
|
||||
**Donation Webhooks** (`POST /api/stripe/webhook`)
|
||||
- `checkout.session.completed`: Updates donation to COMPLETED
|
||||
- `checkout.session.expired`: Updates donation to CANCELLED
|
||||
- `payment_intent.payment_failed`: Updates donation to FAILED
|
||||
- `charge.refunded`: Updates donation to REFUNDED
|
||||
- Stores payment metadata (status, email, error info)
|
||||
|
||||
### Subscription System
|
||||
|
||||
**Subscription Flow** (`POST /api/subscriptions/checkout`)
|
||||
- Creates Stripe customer if not exists
|
||||
- Creates subscription checkout session
|
||||
- Validates price ID configuration
|
||||
- Prevents duplicate active subscriptions
|
||||
- Returns session ID and checkout URL
|
||||
- Allows promotion codes
|
||||
- Requires Bearer token authentication
|
||||
|
||||
**Subscription Portal** (`POST /api/subscriptions/portal`)
|
||||
- Generates Stripe customer portal link
|
||||
- Users can manage/cancel subscriptions
|
||||
- Requires authentication
|
||||
|
||||
**Subscription Webhooks** (`POST /api/stripe/webhook`)
|
||||
- `customer.subscription.created`: Creates `Subscription` record, updates user tier to premium
|
||||
- `customer.subscription.updated`: Updates `Subscription` and user tier/limit
|
||||
- `customer.subscription.deleted`: Downgrades user to free tier
|
||||
- `invoice.payment_succeeded`: Ensures subscription marked active
|
||||
- `invoice.payment_failed`: Sets subscription status to past_due
|
||||
|
||||
**Webhook Payload Handling**
|
||||
- Verifies Stripe signature
|
||||
- Extracts userId from subscription metadata
|
||||
- Extracts pricing tier and interval from price ID
|
||||
- Updates both `Subscription` and `User` models atomically
|
||||
|
||||
### Conversation Limit Management
|
||||
|
||||
**Limit Checking** (`checkConversationLimit()`)
|
||||
- Validates user subscription tier and count
|
||||
- Resets monthly counter if period expired
|
||||
- Premium users with active subscriptions get unlimited access
|
||||
- Free users get 10/month limit
|
||||
- Automatic monthly reset calculation
|
||||
|
||||
**Limit Enforcement**
|
||||
- Checked before creating new conversation
|
||||
- Returns: `{ allowed, remaining, limit, tier, resetDate }`
|
||||
- Returns infinite remaining for premium users
|
||||
|
||||
**Limit Increment** (`incrementConversationCount()`)
|
||||
- Called when new conversation created
|
||||
- Sets initial reset date if not set (1 month from now)
|
||||
- Increments counter by 1
|
||||
|
||||
---
|
||||
|
||||
## 4. API STRUCTURE & ENDPOINTS
|
||||
|
||||
### Framework & Runtime
|
||||
- **Framework**: Next.js 15.5.3 with App Router
|
||||
- **Runtime**: All routes set to `nodejs` (not Edge)
|
||||
- **Response Format**: JSON via `NextResponse`
|
||||
|
||||
### API Categories
|
||||
|
||||
#### Authentication Endpoints
|
||||
|
||||
**User Auth**
|
||||
- `POST /api/auth/register` - User registration with email/password
|
||||
- `POST /api/auth/login` - User login
|
||||
- `GET /api/auth/me` - Get authenticated user profile
|
||||
- `POST /api/auth/logout` - Logout (clear token)
|
||||
|
||||
**Admin Auth**
|
||||
- `POST /api/admin/auth/login` - Admin login with role validation
|
||||
- `GET /api/admin/auth/me` - Get admin profile
|
||||
- `POST /api/admin/auth/logout` - Admin logout
|
||||
|
||||
#### Bible Data Endpoints
|
||||
|
||||
**Bible Versions**
|
||||
- `GET /api/bible/versions` - List Bible versions by language
|
||||
- Query params: `locale`, `all`, `limit`, `search`
|
||||
- Caching: 1 hour cache with 2-hour stale-while-revalidate
|
||||
|
||||
**Bible Books**
|
||||
- `GET /api/bible/books` - Get books for a version
|
||||
|
||||
**Bible Chapters**
|
||||
- `GET /api/bible/chapter` - Get full chapter with verses
|
||||
|
||||
**Bible Verses**
|
||||
- `GET /api/bible/verses` - Get specific verses
|
||||
- `GET /api/bible/search` - Search verses
|
||||
|
||||
**SEO URLs**
|
||||
- `GET /api/bible/seo-url` - Convert friendly URLs to references
|
||||
|
||||
#### User Content Endpoints
|
||||
|
||||
**Bookmarks**
|
||||
- `GET /api/bookmarks/all` - Get all user bookmarks (verses & chapters)
|
||||
- `POST /api/bookmarks/verse` - Create verse bookmark
|
||||
- `GET /api/bookmarks/verse/check` - Check if verse bookmarked
|
||||
- `POST /api/bookmarks/verse/bulk-check` - Check multiple verses
|
||||
- `POST /api/bookmarks/chapter` - Create chapter bookmark
|
||||
- `GET /api/bookmarks/chapter/check` - Check if chapter bookmarked
|
||||
|
||||
**Highlights**
|
||||
- `GET /api/highlights` - Get user highlights
|
||||
- `POST /api/highlights` - Create highlight with color/tags/notes
|
||||
- `PUT /api/highlights/[id]` - Update highlight
|
||||
- `DELETE /api/highlights/[id]` - Delete highlight
|
||||
- `POST /api/highlights/bulk` - Bulk operations
|
||||
|
||||
**Notes**
|
||||
- Not shown in list but available through verse endpoints
|
||||
|
||||
#### User Management Endpoints
|
||||
|
||||
**Profile**
|
||||
- `GET /api/user/profile` - Get user profile
|
||||
- `PUT /api/user/profile` - Update profile
|
||||
|
||||
**Settings**
|
||||
- `GET /api/user/settings` - Get user settings
|
||||
- `PUT /api/user/settings` - Update settings
|
||||
|
||||
**Favorite Version**
|
||||
- `PUT /api/user/favorite-version` - Set default Bible version
|
||||
|
||||
**Reading Progress**
|
||||
- `GET /api/user/reading-progress` - Get reading position
|
||||
- `PUT /api/user/reading-progress` - Update reading position
|
||||
|
||||
#### Reading Plans Endpoints
|
||||
|
||||
**Reading Plans**
|
||||
- `GET /api/reading-plans` - List all available plans
|
||||
|
||||
**User Reading Plans**
|
||||
- `GET /api/user/reading-plans` - Get user's reading plans with status filter
|
||||
- `POST /api/user/reading-plans` - Enroll in plan or create custom plan
|
||||
- `GET /api/user/reading-plans/[id]` - Get specific plan details
|
||||
- `PUT /api/user/reading-plans/[id]` - Update plan
|
||||
- `DELETE /api/user/reading-plans/[id]` - Cancel plan
|
||||
|
||||
**Reading Progress**
|
||||
- `GET /api/user/reading-plans/[id]/progress` - Get progress for plan
|
||||
- `POST /api/user/reading-plans/[id]/progress` - Log reading for day
|
||||
- `PUT /api/user/reading-plans/[id]/progress` - Update progress
|
||||
|
||||
#### Prayer Endpoints
|
||||
|
||||
**Prayer Requests**
|
||||
- `GET /api/prayers` - List public prayer requests (with filters)
|
||||
- Query params: `category`, `limit`, `visibility`, `languages`
|
||||
- Supports public/private filtering based on auth
|
||||
- `POST /api/prayers` - Create prayer request
|
||||
- Supports anonymous or authenticated
|
||||
- `GET /api/prayers/[id]` - Get prayer details
|
||||
- `POST /api/prayers/[id]/pray` - Log prayer for request
|
||||
- `PUT /api/prayers/[id]` - Update prayer (owner only)
|
||||
- `DELETE /api/prayers/[id]` - Delete prayer (owner only)
|
||||
|
||||
**Prayer Generation**
|
||||
- `POST /api/prayers/generate` - Generate prayer prompt (AI)
|
||||
|
||||
#### Chat Endpoints
|
||||
|
||||
**Chat Conversations**
|
||||
- `GET /api/chat/conversations` - List user conversations (auth required)
|
||||
- `POST /api/chat/conversations` - Create new conversation
|
||||
- `GET /api/chat/conversations/[id]` - Get conversation with messages
|
||||
- `PUT /api/chat/conversations/[id]` - Update conversation
|
||||
- `DELETE /api/chat/conversations/[id]` - Delete conversation
|
||||
|
||||
**Chat Messages**
|
||||
- `POST /api/chat` - Send message and get AI response
|
||||
- Status: Currently disabled (returns 503)
|
||||
- Requires Bearer token auth for new conversations
|
||||
- Checks conversation limits for free users
|
||||
- Integrates with Azure OpenAI
|
||||
- Stores messages in database for authenticated users
|
||||
- Uses vector search for relevant Bible verses
|
||||
|
||||
#### Subscription & Payment Endpoints
|
||||
|
||||
**Donations**
|
||||
- `POST /api/stripe/checkout` - Create donation checkout session
|
||||
|
||||
**Subscriptions**
|
||||
- `POST /api/subscriptions/checkout` - Create subscription checkout session
|
||||
- `POST /api/subscriptions/portal` - Get customer portal URL
|
||||
|
||||
**Webhooks**
|
||||
- `POST /api/stripe/webhook` - Stripe webhook handler
|
||||
|
||||
#### Admin Endpoints
|
||||
|
||||
**Users**
|
||||
- `GET /api/admin/users` - List users with pagination/filtering
|
||||
- Query params: `page`, `pageSize`, `search`, `role`
|
||||
- Returns user counts (conversations, prayers, bookmarks)
|
||||
- `GET /api/admin/users/[id]` - Get user details
|
||||
- `PUT /api/admin/users/[id]` - Update user
|
||||
- `DELETE /api/admin/users/[id]` - Delete user
|
||||
|
||||
**Chat Management**
|
||||
- `GET /api/admin/chat/conversations` - List all conversations
|
||||
- `GET /api/admin/chat/conversations/[id]` - Get conversation details
|
||||
- `DELETE /api/admin/chat/conversations/[id]` - Delete conversation
|
||||
|
||||
**Content Management**
|
||||
- `GET /api/admin/pages` - List CMS pages
|
||||
- `POST /api/admin/pages` - Create page
|
||||
- `GET /api/admin/pages/[id]` - Get page
|
||||
- `PUT /api/admin/pages/[id]` - Update page
|
||||
- `DELETE /api/admin/pages/[id]` - Delete page
|
||||
|
||||
**Prayer Requests**
|
||||
- `GET /api/admin/content/prayer-requests` - List all prayers
|
||||
- `GET /api/admin/content/prayer-requests/[id]` - Get prayer details
|
||||
- `PUT /api/admin/content/prayer-requests/[id]` - Update prayer
|
||||
- `DELETE /api/admin/content/prayer-requests/[id]` - Delete prayer
|
||||
|
||||
**Media Management**
|
||||
- `POST /api/admin/media` - Upload media files
|
||||
- `DELETE /api/admin/media/[id]` - Delete media
|
||||
|
||||
**Social Media**
|
||||
- `GET /api/admin/social-media` - List social links
|
||||
- `POST /api/admin/social-media` - Create social link
|
||||
- `PUT /api/admin/social-media/[id]` - Update social link
|
||||
- `DELETE /api/admin/social-media/[id]` - Delete social link
|
||||
|
||||
**Email Configuration**
|
||||
- `GET /api/admin/mailgun` - Get Mailgun settings
|
||||
- `PUT /api/admin/mailgun` - Update Mailgun settings
|
||||
- `POST /api/admin/mailgun/test` - Test email connection
|
||||
|
||||
#### Analytics Endpoints
|
||||
|
||||
**Overview**
|
||||
- `GET /api/admin/analytics/overview` - Comprehensive dashboard stats
|
||||
- Period-based stats (default 30 days)
|
||||
- User metrics: total, new, active
|
||||
- Content metrics: prayers, requests, conversations
|
||||
- Category distributions
|
||||
- Daily activity breakdown
|
||||
|
||||
**Content Analytics**
|
||||
- `GET /api/admin/analytics/content` - Content-specific metrics
|
||||
|
||||
**User Analytics**
|
||||
- `GET /api/admin/analytics/users` - User behavior metrics
|
||||
|
||||
**Real-time Analytics**
|
||||
- `GET /api/admin/analytics/realtime` - Current active users/activity
|
||||
|
||||
**Stats**
|
||||
- `GET /api/stats` - Public statistics
|
||||
|
||||
#### System Endpoints
|
||||
|
||||
**Health Check**
|
||||
- `GET /api/health` - API health status
|
||||
|
||||
**System Health**
|
||||
- `GET /api/admin/system/health` - Detailed system health
|
||||
|
||||
**System Backup**
|
||||
- `POST /api/admin/system/backup` - Database backup
|
||||
|
||||
#### Utility Endpoints
|
||||
|
||||
**Contact Form**
|
||||
- `POST /api/contact` - Contact form submission
|
||||
|
||||
**CAPTCHA**
|
||||
- `POST /api/captcha` - CAPTCHA verification
|
||||
|
||||
**Debug Endpoints** (Development)
|
||||
- `GET /api/debug/user` - Debug user info
|
||||
- `GET /api/debug/schema` - Debug schema info
|
||||
- `GET /api/debug/token` - Debug token info
|
||||
|
||||
---
|
||||
|
||||
## 5. KEY BUSINESS LOGIC & FEATURES
|
||||
|
||||
### Conversation Limit System
|
||||
- Free tier: 10 conversations/month
|
||||
- Premium tier: Unlimited
|
||||
- Automatic monthly reset
|
||||
- Prevents over-usage
|
||||
|
||||
### Prayer System Features
|
||||
- Public/private prayers
|
||||
- Anonymous submission support
|
||||
- Prayer count tracking (IP-based for anonymous, user-based for authenticated)
|
||||
- Category classification (personal, family, health, work, ministry, world)
|
||||
- Language-aware filtering
|
||||
- Multi-language support
|
||||
|
||||
### Reading Plans
|
||||
- Predefined and custom plans
|
||||
- Daily tracking with streak system
|
||||
- Progress tracking with completion status
|
||||
- Reminder system (enabled/disabled with time)
|
||||
- Flexible JSON-based schedules
|
||||
- Target date management
|
||||
|
||||
### Chat System (Currently Disabled)
|
||||
- Conversation persistence
|
||||
- Message history tracking
|
||||
- Integration with Azure OpenAI
|
||||
- Vector search for Bible verses
|
||||
- Context-aware responses
|
||||
- Multi-language system prompts
|
||||
- Subscription-based limit enforcement
|
||||
|
||||
### Vector Search (BiblePassage model)
|
||||
- Embedding-based verse search
|
||||
- Language-specific filtering
|
||||
- Used for AI chat context
|
||||
- Supports: EN (ASV), ES (RVA 1909), etc.
|
||||
|
||||
### Admin System Features
|
||||
- User management with pagination
|
||||
- Chat conversation moderation
|
||||
- Content (pages) management with SEO
|
||||
- Prayer request moderation
|
||||
- Media file management
|
||||
- Social media link management
|
||||
- Email service configuration (Mailgun)
|
||||
- Comprehensive analytics dashboard
|
||||
- Daily activity tracking
|
||||
- System health monitoring
|
||||
|
||||
---
|
||||
|
||||
## 6. EXTERNAL INTEGRATIONS
|
||||
|
||||
### Stripe
|
||||
- Payment processing (one-time)
|
||||
- Subscription management (recurring)
|
||||
- Webhook event handling
|
||||
- Customer portal access
|
||||
|
||||
### Azure OpenAI
|
||||
- Chat completions API
|
||||
- Multi-language support
|
||||
- Temperature/top_p configuration
|
||||
- Content filtering detection
|
||||
- Fallback responses
|
||||
|
||||
### Mailgun
|
||||
- Email service
|
||||
- Contact form handling
|
||||
- Password reset emails
|
||||
- Test mode support
|
||||
- Multi-region support (US/EU)
|
||||
|
||||
### Vector Database
|
||||
- PostgreSQL with pgvector extension
|
||||
- Bible verse embeddings
|
||||
- Hybrid search capability
|
||||
|
||||
---
|
||||
|
||||
## 7. FILE STORAGE
|
||||
|
||||
### System
|
||||
- **Model**: `MediaFile`
|
||||
- **Fields**: filename, originalName, mimeType, size, path, url, alt, uploadedBy
|
||||
- **Tracking**: User upload attribution, creation timestamp
|
||||
- **Indexes**: By uploader, by MIME type
|
||||
|
||||
### Upload Endpoint
|
||||
- `POST /api/admin/media` - Admin only file uploads
|
||||
|
||||
### Usage in CMS
|
||||
- Pages can have featured images
|
||||
- Supported in content rich text editor
|
||||
|
||||
---
|
||||
|
||||
## 8. TECHNOLOGY STACK
|
||||
|
||||
### Backend Framework
|
||||
- Next.js 15.5.3 (with App Router)
|
||||
- Node.js runtime
|
||||
|
||||
### Database
|
||||
- PostgreSQL
|
||||
- Prisma ORM 6.16.2
|
||||
- pgvector for embeddings
|
||||
|
||||
### Authentication & Security
|
||||
- JWT (jsonwebtoken 9.0.2)
|
||||
- bcryptjs for password hashing (3.0.2)
|
||||
- CORS headers management
|
||||
|
||||
### API Integration
|
||||
- Stripe 19.1.0 (payments)
|
||||
- Mailgun.js 12.0.3 (email)
|
||||
- OpenAI 5.22.0 (Azure OpenAI)
|
||||
- Socket.io 4.8.1 (WebSocket support)
|
||||
|
||||
### Validation
|
||||
- Zod 3.25.76 (schema validation)
|
||||
|
||||
### State Management
|
||||
- Zustand 5.0.8
|
||||
|
||||
### Utilities
|
||||
- uuid 13.0.0 (ID generation)
|
||||
- axios (via stripe, mailgun, openai)
|
||||
|
||||
### Development
|
||||
- TypeScript 5.9.2
|
||||
- tsx 4.20.5 (TypeScript runner)
|
||||
|
||||
---
|
||||
|
||||
## 9. ENVIRONMENT VARIABLES
|
||||
|
||||
### Database
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `VECTOR_SCHEMA` - Schema name for vector tables (default: ai_bible)
|
||||
- `EMBED_DIMS` - Embedding dimensions (default: 1536)
|
||||
|
||||
### JWT & Auth
|
||||
- `JWT_SECRET` - Secret key for JWT signing
|
||||
|
||||
### Stripe
|
||||
- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` - Public key (client-side)
|
||||
- `STRIPE_SECRET_KEY` - Secret key (server-side)
|
||||
- `STRIPE_WEBHOOK_SECRET` - Webhook signature verification
|
||||
- `STRIPE_PREMIUM_MONTHLY_PRICE_ID` - Monthly subscription price ID
|
||||
- `STRIPE_PREMIUM_YEARLY_PRICE_ID` - Yearly subscription price ID
|
||||
|
||||
### Azure OpenAI
|
||||
- `AZURE_OPENAI_ENDPOINT` - API endpoint
|
||||
- `AZURE_OPENAI_KEY` - API key
|
||||
- `AZURE_OPENAI_DEPLOYMENT` - Model deployment name
|
||||
- `AZURE_OPENAI_API_VERSION` - API version
|
||||
|
||||
### Email (Mailgun)
|
||||
- Configured in database `MailgunSettings` table (encrypted in DB)
|
||||
- Can be updated via admin panel
|
||||
|
||||
### Application
|
||||
- `NEXTAUTH_URL` - Base URL for callbacks
|
||||
- `NODE_ENV` - Environment (development/production)
|
||||
|
||||
---
|
||||
|
||||
## 10. CURRENT STATUS & NOTES
|
||||
|
||||
### Disabled Features
|
||||
- Chat feature currently disabled (returns 503 Service Unavailable)
|
||||
- Reason: Likely maintenance or missing Azure OpenAI configuration
|
||||
|
||||
### Pending Implementation
|
||||
- Password reset functionality (structure in place, not fully implemented)
|
||||
- WebSocket support (server available but not actively used)
|
||||
|
||||
### Security Considerations
|
||||
- Mailgun API keys stored encrypted in database
|
||||
- JWT secrets required for all environments
|
||||
- Admin tokens use httpOnly cookies for CSRF protection
|
||||
- Stripe webhook signature verification implemented
|
||||
- User data cascades deleted appropriately
|
||||
|
||||
### Performance Optimizations
|
||||
- Database indexes on frequently queried fields
|
||||
- Composite indexes for common filter combinations
|
||||
- Unique constraints to prevent duplicates
|
||||
- Pagination in admin list endpoints
|
||||
- Select-only fields to reduce data transfer
|
||||
|
||||
### Monitoring & Logging
|
||||
- Extensive console logging throughout API routes
|
||||
- Error tracking in webhook handlers
|
||||
- Analytics dashboard for monitoring usage patterns
|
||||
|
||||
---
|
||||
|
||||
## 11. DATA RELATIONSHIPS DIAGRAM
|
||||
|
||||
```
|
||||
User (1)
|
||||
├── (1:M) Session
|
||||
├── (1:M) ChatConversation -> (1:M) ChatMessage
|
||||
├── (1:M) Bookmark -> BibleVerse -> BibleChapter -> BibleBook -> BibleVersion
|
||||
├── (1:M) ChapterBookmark -> BibleBook
|
||||
├── (1:M) Highlight -> BibleVerse
|
||||
├── (1:M) Note -> BibleVerse
|
||||
├── (1:M) ReadingHistory -> BibleVersion
|
||||
├── (1:M) PrayerRequest -> (1:M) Prayer & UserPrayer
|
||||
├── (1:M) UserReadingPlan -> ReadingPlan & UserReadingProgress
|
||||
├── (1:M) Donation
|
||||
├── (1:M) Subscription
|
||||
├── (1:M) Page (created & updated)
|
||||
├── (1:M) MediaFile
|
||||
├── (1:M) SocialMediaLink (created & updated)
|
||||
└── (1:M) MailgunSettings (updated)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. API RESPONSE PATTERNS
|
||||
|
||||
### Success Response
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {},
|
||||
"message": "Optional message"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error message",
|
||||
"details": [] // Optional validation details
|
||||
}
|
||||
```
|
||||
|
||||
### Pagination Response
|
||||
```json
|
||||
{
|
||||
"data": [],
|
||||
"pagination": {
|
||||
"page": 0,
|
||||
"pageSize": 10,
|
||||
"total": 100,
|
||||
"totalPages": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. DEPLOYMENT CONSIDERATIONS
|
||||
|
||||
### Build Configuration
|
||||
- `NODE_OPTIONS='--max-old-space-size=4096'` for standard builds
|
||||
- `NODE_OPTIONS='--max-old-space-size=8192'` for production builds
|
||||
- `NEXT_PRIVATE_SKIP_SIZE_LIMIT=1` available for fast builds (skips size check)
|
||||
|
||||
### Production Checklist
|
||||
- All environment variables configured
|
||||
- Database migrations applied (`prisma migrate deploy`)
|
||||
- Stripe webhooks configured with correct URL
|
||||
- Azure OpenAI credentials validated
|
||||
- Mailgun settings configured and tested
|
||||
- CORS headers for cross-origin requests
|
||||
- HTTPS enforced for secure cookies
|
||||
- Database backups enabled
|
||||
- Monitoring/alerting configured
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Biblical Guide backend is a sophisticated, well-structured application with:
|
||||
- Comprehensive user management and authentication
|
||||
- Flexible subscription/payment system via Stripe
|
||||
- Rich content management for Bible data
|
||||
- Multi-feature user engagement (bookmarks, notes, highlights, prayers, reading plans)
|
||||
- Full admin panel for system management
|
||||
- AI-powered chat with context awareness
|
||||
- Scalable architecture with proper indexing and data relationships
|
||||
|
||||
The codebase demonstrates good practices in API design, security, and data modeling with room for future enhancements in performance optimization and additional features.
|
||||
350
BACKEND_DOCUMENTATION_INDEX.md
Normal file
350
BACKEND_DOCUMENTATION_INDEX.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Backend Documentation Index
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains comprehensive documentation of the Biblical Guide backend architecture. The analysis covers the complete backend system including database design, authentication, APIs, payment processing, and admin functionality.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
### 1. BACKEND_ARCHITECTURE_ANALYSIS.md (Primary Document)
|
||||
**Size**: 29 KB | **Lines**: 890 | **Sections**: 13
|
||||
|
||||
Complete architectural documentation covering:
|
||||
|
||||
- **Section 1**: Database Schema (32 models)
|
||||
- User Management
|
||||
- Bible Data Models
|
||||
- User Content Models
|
||||
- Communication Models
|
||||
- Prayer System Models
|
||||
- Reading Plans Models
|
||||
- Payment & Subscription Models
|
||||
- Content Management Models
|
||||
- User Preferences
|
||||
|
||||
- **Section 2**: Authentication System
|
||||
- JWT Token Architecture
|
||||
- User Registration/Login Flow
|
||||
- Admin Authentication
|
||||
- Permission System
|
||||
- Client-Side Auth Management
|
||||
|
||||
- **Section 3**: Payment & Subscription System
|
||||
- Stripe Configuration
|
||||
- Donation Flow
|
||||
- Subscription Flow
|
||||
- Webhook Handling
|
||||
- Conversation Limit Management
|
||||
|
||||
- **Section 4**: API Structure & Endpoints
|
||||
- Framework & Runtime
|
||||
- 12 API Categories
|
||||
- 70+ Documented Endpoints
|
||||
- Request/Response Patterns
|
||||
|
||||
- **Section 5**: Key Business Logic
|
||||
- Conversation Limit System
|
||||
- Prayer System Features
|
||||
- Reading Plans
|
||||
- Chat System
|
||||
- Vector Search
|
||||
- Admin Features
|
||||
|
||||
- **Section 6-13**: Additional Topics
|
||||
- External Integrations
|
||||
- File Storage
|
||||
- Technology Stack
|
||||
- Environment Variables
|
||||
- Current Status & Notes
|
||||
- Data Relationships
|
||||
- API Response Patterns
|
||||
- Deployment Considerations
|
||||
|
||||
**Best For**: Comprehensive understanding, onboarding new developers, system design decisions
|
||||
|
||||
---
|
||||
|
||||
### 2. BACKEND_QUICK_REFERENCE.md (Quick Lookup)
|
||||
**Size**: 9.2 KB | **Lines**: 353 | **Sections**: 14
|
||||
|
||||
Quick reference guide for common tasks:
|
||||
|
||||
- **Model Index**: 32 models organized by category
|
||||
- **Authentication Table**: Endpoints, methods, auth requirements
|
||||
- **API Endpoints**: Organized by category with example URLs
|
||||
- **Subscription Tiers**: Feature comparison table
|
||||
- **Data Constraints**: Unique constraints and cascades
|
||||
- **Webhook Events**: Stripe events and their effects
|
||||
- **Admin Permissions**: Role-based access matrix
|
||||
- **Limits & Defaults**: Important configuration values
|
||||
- **Query Patterns**: Common Prisma queries
|
||||
- **Environment Checklist**: Required variables
|
||||
- **Development Tasks**: Common npm scripts
|
||||
- **Performance Tips**: Optimization guidelines
|
||||
- **Troubleshooting**: Common issues and solutions
|
||||
- **Resource Links**: External documentation
|
||||
|
||||
**Best For**: Day-to-day reference, quick lookups, during development
|
||||
|
||||
---
|
||||
|
||||
## Key Information at a Glance
|
||||
|
||||
### Technology Stack
|
||||
| Component | Technology | Version |
|
||||
|-----------|-----------|---------|
|
||||
| Framework | Next.js | 15.5.3 |
|
||||
| Database | PostgreSQL | Latest |
|
||||
| ORM | Prisma | 6.16.2 |
|
||||
| Auth | JWT | via jsonwebtoken 9.0.2 |
|
||||
| Payments | Stripe | 19.1.0 |
|
||||
| Email | Mailgun | 12.0.3 |
|
||||
| AI | Azure OpenAI | Custom |
|
||||
| Validation | Zod | 3.25.76 |
|
||||
|
||||
### Database Statistics
|
||||
- **Total Models**: 32
|
||||
- **Total Indexes**: 25+
|
||||
- **Unique Constraints**: 20+
|
||||
- **Foreign Key Cascades**: 8
|
||||
- **Text Fields**: 15+ (for long content)
|
||||
- **JSON Fields**: 5 (for flexible data)
|
||||
|
||||
### API Statistics
|
||||
- **Total Endpoints**: 70+
|
||||
- **Public Endpoints**: 15
|
||||
- **Protected Endpoints**: 40
|
||||
- **Admin Endpoints**: 25+
|
||||
- **Webhook Endpoints**: 2
|
||||
- **Categories**: 12
|
||||
|
||||
### Authentication
|
||||
- **User Token Expiry**: 7 days
|
||||
- **Admin Token Expiry**: 24 hours (8 hours for cookie)
|
||||
- **Password Hash Rounds**: 10 (bcryptjs)
|
||||
- **Session Expiry**: 7 days
|
||||
- **Admin Roles**: Admin, Moderator
|
||||
- **Permission Types**: 13
|
||||
|
||||
### Subscription System
|
||||
- **Free Tier Limit**: 10 conversations/month
|
||||
- **Premium Tier Limit**: Unlimited
|
||||
- **Webhook Events Handled**: 9+
|
||||
- **Payment Methods**: Stripe (card)
|
||||
- **Donation Presets**: $5, $10, $25, $50, $100, $250
|
||||
|
||||
## Quick Start References
|
||||
|
||||
### Common Tasks
|
||||
|
||||
**Find Information About**:
|
||||
- Specific API endpoint → Search "API STRUCTURE" in ARCHITECTURE_ANALYSIS.md or check QUICK_REFERENCE.md
|
||||
- Database model → "DATABASE SCHEMA" in ARCHITECTURE_ANALYSIS.md or MODEL quick index
|
||||
- Authentication → "AUTHENTICATION SYSTEM" section
|
||||
- Payment flow → "PAYMENT & SUBSCRIPTION SYSTEM" section
|
||||
- Admin panel → "ADMIN ENDPOINTS" in QUICK_REFERENCE.md
|
||||
|
||||
**For Development**:
|
||||
- Set up environment → QUICK_REFERENCE.md "Environment Setup Checklist"
|
||||
- Common database queries → QUICK_REFERENCE.md "Common Query Patterns"
|
||||
- API testing → Check each endpoint in ARCHITECTURE_ANALYSIS.md Section 4
|
||||
- Troubleshooting → QUICK_REFERENCE.md "Troubleshooting" section
|
||||
|
||||
**For Deployment**:
|
||||
- Production checklist → ARCHITECTURE_ANALYSIS.md Section 13
|
||||
- Environment variables → ARCHITECTURE_ANALYSIS.md Section 9
|
||||
- Migrations → QUICK_REFERENCE.md "Common Development Tasks"
|
||||
- Monitoring → ARCHITECTURE_ANALYSIS.md "Monitoring & Logging"
|
||||
|
||||
## Data Models by Feature
|
||||
|
||||
### Bible Reading
|
||||
- BibleVersion, BibleBook, BibleChapter, BibleVerse
|
||||
- BiblePassage (with embeddings)
|
||||
- ReadingHistory
|
||||
|
||||
### User Content
|
||||
- Bookmark, ChapterBookmark
|
||||
- Highlight, Note
|
||||
- ReadingHistory
|
||||
|
||||
### User Engagement
|
||||
- PrayerRequest, Prayer, UserPrayer
|
||||
- ReadingPlan, UserReadingPlan, UserReadingProgress
|
||||
- ChatConversation, ChatMessage
|
||||
|
||||
### Monetization
|
||||
- Subscription, Donation
|
||||
- User (subscription fields)
|
||||
|
||||
### Administration
|
||||
- Page, MediaFile
|
||||
- SocialMediaLink, MailgunSettings
|
||||
|
||||
## Security Features
|
||||
|
||||
### Authentication
|
||||
- JWT-based token authentication
|
||||
- bcryptjs password hashing (10 rounds)
|
||||
- Session tracking in database
|
||||
- HttpOnly cookies for admin tokens
|
||||
- CSRF protection via SameSite
|
||||
|
||||
### Authorization
|
||||
- Role-based access control (User/Admin/Moderator)
|
||||
- Fine-grained permissions (13 types)
|
||||
- Per-endpoint permission checks
|
||||
- Cascade deletion on user removal
|
||||
|
||||
### Data Protection
|
||||
- Encrypted Mailgun API keys in database
|
||||
- Stripe webhook signature verification
|
||||
- Secure token generation (UUID)
|
||||
- Proper SQL parameter binding via Prisma
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Database
|
||||
- Strategic indexing on frequently queried fields
|
||||
- Composite indexes for complex queries
|
||||
- Unique constraints prevent duplicates
|
||||
- Select-only queries reduce data transfer
|
||||
- Proper relationship handling with include
|
||||
|
||||
### API
|
||||
- Bible version caching (1 hour + 2hr stale-while-revalidate)
|
||||
- Pagination for list endpoints
|
||||
- Selective field selection
|
||||
- Connection pooling via Prisma
|
||||
|
||||
### Frontend
|
||||
- JWT stored in localStorage
|
||||
- Client-side token expiration check
|
||||
- Lazy loading of relationships
|
||||
|
||||
## Disabled Features
|
||||
|
||||
### Chat Feature (Currently Disabled)
|
||||
- **Endpoint**: `POST /api/chat`
|
||||
- **Status**: Returns 503 Service Unavailable
|
||||
- **Reason**: Azure OpenAI configuration needed
|
||||
- **Features Blocked**:
|
||||
- AI responses
|
||||
- Vector search for Bible verses
|
||||
- Conversation persistence
|
||||
- Limit enforcement
|
||||
|
||||
### Password Reset
|
||||
- Structure in place but incomplete
|
||||
- Mailgun integration available
|
||||
- Email template defined
|
||||
|
||||
## Integration Points
|
||||
|
||||
### External Services
|
||||
1. **Stripe** - Payments, subscriptions, webhooks
|
||||
2. **Azure OpenAI** - AI chat responses
|
||||
3. **Mailgun** - Email delivery
|
||||
4. **PostgreSQL** - Data persistence
|
||||
5. **pgvector** - Vector embeddings (optional)
|
||||
|
||||
### Internal Services
|
||||
1. **JWT** - Token generation/verification
|
||||
2. **bcryptjs** - Password hashing
|
||||
3. **Zod** - Input validation
|
||||
4. **Prisma** - Database ORM
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
When modifying the backend:
|
||||
|
||||
1. **Database Changes**
|
||||
- Update schema.prisma
|
||||
- Create migration: `npx prisma migrate dev`
|
||||
- Update BACKEND_ARCHITECTURE_ANALYSIS.md
|
||||
|
||||
2. **API Changes**
|
||||
- Follow existing patterns in /app/api
|
||||
- Use Zod schemas for validation
|
||||
- Add error handling with NextResponse
|
||||
- Update BACKEND_QUICK_REFERENCE.md endpoint list
|
||||
|
||||
3. **Authentication Changes**
|
||||
- Update lib/auth/* files
|
||||
- Verify JWT payload structure
|
||||
- Test with client-side auth management
|
||||
- Update ARCHITECTURE_ANALYSIS.md Section 2
|
||||
|
||||
4. **Payment Changes**
|
||||
- Update lib/stripe-server.ts or lib/subscription-utils.ts
|
||||
- Add/update webhook handlers
|
||||
- Update ARCHITECTURE_ANALYSIS.md Section 3
|
||||
- Test with Stripe test keys
|
||||
|
||||
## Related Documents
|
||||
|
||||
### In This Directory
|
||||
- BACKEND_ARCHITECTURE_ANALYSIS.md (this document)
|
||||
- BACKEND_QUICK_REFERENCE.md
|
||||
- BACKEND_DOCUMENTATION_INDEX.md (this file)
|
||||
|
||||
### In Repository Root
|
||||
- README.md (project overview)
|
||||
- package.json (dependencies)
|
||||
- .env.example (environment template)
|
||||
|
||||
### Prisma Files
|
||||
- prisma/schema.prisma (database schema)
|
||||
- prisma/migrations/* (migration history)
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# Database
|
||||
npx prisma migrate deploy # Apply migrations
|
||||
npx prisma generate # Generate Prisma client
|
||||
npx prisma studio # Open database UI
|
||||
|
||||
# Development
|
||||
npm run dev # Start dev server
|
||||
npm run build # Build for production
|
||||
npm run import-bible # Import Bible data
|
||||
|
||||
# Analysis
|
||||
grep -r "export async function" app/api/ # Find all endpoints
|
||||
grep -r "model " prisma/schema.prisma # List all models
|
||||
```
|
||||
|
||||
## Support & Questions
|
||||
|
||||
### For Understanding
|
||||
1. Read BACKEND_QUICK_REFERENCE.md first (faster)
|
||||
2. Dive into BACKEND_ARCHITECTURE_ANALYSIS.md for details
|
||||
3. Check specific endpoint files in app/api/
|
||||
|
||||
### For Debugging
|
||||
1. Check QUICK_REFERENCE.md "Troubleshooting"
|
||||
2. Enable logging: `log: ['query', 'error']` in Prisma client
|
||||
3. Use `npx prisma studio` to inspect data
|
||||
4. Check API route logs and error messages
|
||||
|
||||
### For Adding Features
|
||||
1. Plan database changes in schema.prisma
|
||||
2. Create API route in app/api/
|
||||
3. Update documentation
|
||||
4. Test with auth headers if needed
|
||||
5. Configure webhooks if needed
|
||||
|
||||
## Version History
|
||||
|
||||
| Date | Changes | Version |
|
||||
|------|---------|---------|
|
||||
| 2025-11-05 | Initial comprehensive analysis | 1.0 |
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: November 5, 2025
|
||||
**Document Version**: 1.0
|
||||
**Backend Status**: Production-ready (chat feature disabled)
|
||||
|
||||
For the latest information, always refer to the source files in `/root/biblical-guide/`.
|
||||
353
BACKEND_QUICK_REFERENCE.md
Normal file
353
BACKEND_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Backend Quick Reference Guide
|
||||
|
||||
## Database Models Quick Index
|
||||
|
||||
### User Management (3 models)
|
||||
- **User** - Main user account, auth, subscription tracking
|
||||
- **Session** - JWT token sessions (7 day expiry)
|
||||
- **UserPreference** - Key-value user settings store
|
||||
|
||||
### Bible Data (5 models)
|
||||
- **BibleVersion** - Multi-language Bible versions
|
||||
- **BibleBook** - Books within versions
|
||||
- **BibleChapter** - Chapters within books
|
||||
- **BibleVerse** - Individual verses (searchable)
|
||||
- **BiblePassage** - Verses with embeddings (legacy/vector search)
|
||||
|
||||
### User Content (5 models)
|
||||
- **Bookmark** - Verse bookmarks
|
||||
- **ChapterBookmark** - Chapter bookmarks
|
||||
- **Highlight** - Colored verse highlights with tags
|
||||
- **Note** - User notes on verses
|
||||
- **ReadingHistory** - Reading position tracking
|
||||
|
||||
### Communication (2 models)
|
||||
- **ChatConversation** - Conversation threads
|
||||
- **ChatMessage** - Individual messages (USER/ASSISTANT/SYSTEM roles)
|
||||
|
||||
### Prayer System (3 models)
|
||||
- **PrayerRequest** - Prayer request posts
|
||||
- **Prayer** - Anonymous prayers (IP-based tracking)
|
||||
- **UserPrayer** - Authenticated prayers
|
||||
|
||||
### Reading Plans (3 models)
|
||||
- **ReadingPlan** - Predefined/custom reading schedules
|
||||
- **UserReadingPlan** - User enrollment with progress/streaks
|
||||
- **UserReadingProgress** - Daily reading logs
|
||||
|
||||
### Payment (2 models)
|
||||
- **Subscription** - Active Stripe subscriptions
|
||||
- **Donation** - One-time/recurring donations
|
||||
|
||||
### Content Management (4 models)
|
||||
- **Page** - CMS pages (DRAFT/PUBLISHED/ARCHIVED)
|
||||
- **MediaFile** - Uploaded files/images
|
||||
- **SocialMediaLink** - Footer social links
|
||||
- **MailgunSettings** - Email service config
|
||||
|
||||
---
|
||||
|
||||
## Authentication Quick Reference
|
||||
|
||||
| Purpose | Endpoint | Method | Auth Required |
|
||||
|---------|----------|--------|---------------|
|
||||
| Register | `/api/auth/register` | POST | No |
|
||||
| Login | `/api/auth/login` | POST | No |
|
||||
| Get Profile | `/api/auth/me` | GET | Bearer token |
|
||||
| Logout | `/api/auth/logout` | POST | Bearer token |
|
||||
| Admin Login | `/api/admin/auth/login` | POST | No (role validated) |
|
||||
| Admin Profile | `/api/admin/auth/me` | GET | Admin cookie/Bearer |
|
||||
|
||||
**Token Expiry**: 7 days (users), 24 hours (admins)
|
||||
**Storage**: localStorage (client), httpOnly cookie (admin)
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints by Category
|
||||
|
||||
### Bible Data (Read-only, Public)
|
||||
```
|
||||
GET /api/bible/versions?locale=ro&limit=10
|
||||
GET /api/bible/books?versionId=...
|
||||
GET /api/bible/chapter?bookId=...&chapterNum=1
|
||||
GET /api/bible/verses?chapterId=...
|
||||
GET /api/bible/search?query=...
|
||||
GET /api/bible/seo-url?reference=John%203:16
|
||||
```
|
||||
|
||||
### User Content (Protected)
|
||||
```
|
||||
GET /api/bookmarks/all
|
||||
POST/GET /api/bookmarks/verse
|
||||
POST/GET /api/bookmarks/chapter
|
||||
|
||||
GET /api/highlights
|
||||
POST /api/highlights
|
||||
PUT /api/highlights/{id}
|
||||
DELETE /api/highlights/{id}
|
||||
POST /api/highlights/bulk
|
||||
```
|
||||
|
||||
### Prayer System (Semi-public)
|
||||
```
|
||||
GET /api/prayers?category=health&visibility=public&languages=en
|
||||
POST /api/prayers (with or without auth)
|
||||
GET /api/prayers/{id}
|
||||
POST /api/prayers/{id}/pray
|
||||
```
|
||||
|
||||
### Reading Plans (Protected)
|
||||
```
|
||||
GET /api/reading-plans (public list)
|
||||
GET /api/user/reading-plans (user's plans)
|
||||
POST /api/user/reading-plans (enroll)
|
||||
GET /api/user/reading-plans/{id}/progress
|
||||
POST /api/user/reading-plans/{id}/progress
|
||||
```
|
||||
|
||||
### Chat (Protected but Disabled)
|
||||
```
|
||||
POST /api/chat (returns 503)
|
||||
GET /api/chat/conversations
|
||||
POST /api/chat/conversations
|
||||
GET /api/chat/conversations/{id}
|
||||
```
|
||||
|
||||
### Payment & Subscriptions
|
||||
```
|
||||
POST /api/stripe/checkout (donation)
|
||||
POST /api/subscriptions/checkout (subscription)
|
||||
POST /api/subscriptions/portal (manage subscription)
|
||||
POST /api/stripe/webhook (Stripe webhook)
|
||||
```
|
||||
|
||||
### Admin Panel (Admin/Moderator only)
|
||||
```
|
||||
GET /api/admin/users?page=0&pageSize=10&search=...&role=user
|
||||
GET /api/admin/users/{id}
|
||||
|
||||
GET /api/admin/chat/conversations
|
||||
GET /api/admin/content/prayer-requests
|
||||
|
||||
GET /api/admin/analytics/overview?period=30
|
||||
GET /api/admin/analytics/content
|
||||
GET /api/admin/analytics/users
|
||||
|
||||
POST /api/admin/pages (CMS)
|
||||
POST /api/admin/media (file upload)
|
||||
POST /api/admin/social-media (footer links)
|
||||
POST /api/admin/mailgun (email config)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Subscription Tiers
|
||||
|
||||
| Feature | Free | Premium |
|
||||
|---------|------|---------|
|
||||
| Chat Conversations/Month | 10 | Unlimited |
|
||||
| Bible Reading | Unlimited | Unlimited |
|
||||
| Bookmarks | Unlimited | Unlimited |
|
||||
| Notes & Highlights | Unlimited | Unlimited |
|
||||
| Prayer Requests | Unlimited | Unlimited |
|
||||
| Reading Plans | Unlimited | Unlimited |
|
||||
| Cost | Free | Monthly/Yearly |
|
||||
|
||||
---
|
||||
|
||||
## Key Data Constraints
|
||||
|
||||
### Unique Constraints
|
||||
- User email
|
||||
- Session token
|
||||
- Bookmark (userId + verseId)
|
||||
- Highlight (userId + verseId)
|
||||
- ChapterBookmark (userId + bookId + chapterNum)
|
||||
- ReadingHistory (userId + versionId)
|
||||
- BibleVersion (abbreviation + language)
|
||||
- BibleBook (versionId + orderNum)
|
||||
- BibleChapter (bookId + chapterNum)
|
||||
- BibleVerse (chapterId + verseNum)
|
||||
- Prayer (requestId + ipAddress)
|
||||
- UserPrayer (userId + requestId)
|
||||
- SocialMediaLink platform
|
||||
- Page slug
|
||||
|
||||
### Foreign Key Cascades
|
||||
- User → All user content (sessions, bookmarks, conversations, etc.)
|
||||
- BibleVersion → Books, Chapters, Verses
|
||||
- ChatConversation → ChatMessages
|
||||
- PrayerRequest → Prayers, UserPrayers
|
||||
|
||||
---
|
||||
|
||||
## Webhook Events (Stripe)
|
||||
|
||||
| Event | Model Update | User Impact |
|
||||
|-------|--------------|------------|
|
||||
| `checkout.session.completed` | Donation COMPLETED | Payment confirmed |
|
||||
| `checkout.session.expired` | Donation CANCELLED | Session expired |
|
||||
| `payment_intent.payment_failed` | Donation FAILED | Payment failed |
|
||||
| `charge.refunded` | Donation REFUNDED | Refund processed |
|
||||
| `customer.subscription.created` | Subscription created, User tier=premium | Premium access |
|
||||
| `customer.subscription.updated` | Subscription updated | Status change |
|
||||
| `customer.subscription.deleted` | Subscription CANCELLED, User tier=free | Downgraded to free |
|
||||
| `invoice.payment_succeeded` | User subscriptionStatus=active | Payment received |
|
||||
| `invoice.payment_failed` | User subscriptionStatus=past_due | Payment issue |
|
||||
|
||||
---
|
||||
|
||||
## Admin Permissions
|
||||
|
||||
### Admin Role
|
||||
- All permissions (SUPER_ADMIN)
|
||||
- Full system access
|
||||
|
||||
### Moderator Role (Limited)
|
||||
- READ_USERS, WRITE_USERS
|
||||
- READ_CONTENT, WRITE_CONTENT, DELETE_CONTENT
|
||||
- READ_ANALYTICS
|
||||
- READ_CHAT, WRITE_CHAT (not DELETE_CHAT)
|
||||
- NO system backup/health access
|
||||
|
||||
---
|
||||
|
||||
## Important Limits & Defaults
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Free Tier Conversation Limit | 10/month |
|
||||
| Token Expiry (User) | 7 days |
|
||||
| Token Expiry (Admin) | 24 hours |
|
||||
| Session Expiry | 7 days |
|
||||
| Admin Cookie MaxAge | 8 hours |
|
||||
| JWT Algorithm | HS256 |
|
||||
| Password Hash Rounds | 10 (bcryptjs) |
|
||||
| Default Bible Language | "ro" |
|
||||
| Default Currency | "usd" |
|
||||
| Donation Presets | $5, $10, $25, $50, $100, $250 |
|
||||
| Prayer Categories | personal, family, health, work, ministry, world |
|
||||
| Page Status Values | DRAFT, PUBLISHED, ARCHIVED |
|
||||
| Subscription Status Values | ACTIVE, CANCELLED, PAST_DUE, TRIALING, INCOMPLETE, INCOMPLETE_EXPIRED, UNPAID |
|
||||
|
||||
---
|
||||
|
||||
## Common Query Patterns
|
||||
|
||||
### Get User with All Content
|
||||
```prisma
|
||||
user.include({
|
||||
bookmarks: true,
|
||||
highlights: true,
|
||||
notes: true,
|
||||
chatConversations: { include: { messages: true } },
|
||||
userReadingPlans: { include: { plan: true } }
|
||||
})
|
||||
```
|
||||
|
||||
### Get Conversation with Messages
|
||||
```prisma
|
||||
chatConversation.include({
|
||||
messages: {
|
||||
orderBy: { timestamp: 'asc' },
|
||||
take: 50 // Last 50 messages
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Search Prayer Requests
|
||||
```prisma
|
||||
prayerRequest.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
language: { in: ['en', 'ro'] },
|
||||
category: 'health'
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Setup Checklist
|
||||
|
||||
- [ ] DATABASE_URL (PostgreSQL)
|
||||
- [ ] JWT_SECRET (32+ chars)
|
||||
- [ ] STRIPE_SECRET_KEY
|
||||
- [ ] STRIPE_PUBLISHABLE_KEY (public)
|
||||
- [ ] STRIPE_WEBHOOK_SECRET
|
||||
- [ ] STRIPE_PREMIUM_MONTHLY_PRICE_ID
|
||||
- [ ] STRIPE_PREMIUM_YEARLY_PRICE_ID
|
||||
- [ ] AZURE_OPENAI_ENDPOINT
|
||||
- [ ] AZURE_OPENAI_KEY
|
||||
- [ ] AZURE_OPENAI_DEPLOYMENT
|
||||
- [ ] AZURE_OPENAI_API_VERSION
|
||||
- [ ] NEXTAUTH_URL (base URL)
|
||||
- [ ] NODE_ENV (development/production)
|
||||
|
||||
---
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Run Migrations
|
||||
```bash
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Generate Prisma Client
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### View Database
|
||||
```bash
|
||||
npx prisma studio
|
||||
```
|
||||
|
||||
### Seed Database
|
||||
```bash
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
### Import Bible Data
|
||||
```bash
|
||||
npm run import-bible
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use select()** - Only fetch needed fields
|
||||
2. **Add indexes** - Already done for common queries
|
||||
3. **Paginate** - Use skip/take for lists
|
||||
4. **Cache versions** - Bible versions cached 1 hour
|
||||
5. **Batch operations** - Use bulk endpoints
|
||||
6. **Lazy load** - Include relations conditionally
|
||||
7. **Monitor webhooks** - Stripe webhook logs essential
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Check |
|
||||
|-------|-------|
|
||||
| Auth fails | JWT_SECRET set? Token not expired? |
|
||||
| Chat disabled | AZURE_OPENAI_* vars configured? |
|
||||
| Webhook fails | STRIPE_WEBHOOK_SECRET correct? |
|
||||
| Email fails | Mailgun settings in DB enabled? |
|
||||
| Bible data empty | Import script run? BibleVersion exists? |
|
||||
| Prayers not showing | isPublic=true & isActive=true? |
|
||||
| Subscriptions broken | Stripe price IDs match env vars? |
|
||||
|
||||
---
|
||||
|
||||
## Resource Links
|
||||
|
||||
- **Prisma Docs**: https://www.prisma.io/docs/
|
||||
- **Next.js Docs**: https://nextjs.org/docs
|
||||
- **Stripe API**: https://stripe.com/docs/api
|
||||
- **JWT.io**: https://jwt.io/
|
||||
- **Zod Validation**: https://zod.dev/
|
||||
|
||||
910
CROSS_REFERENCES_PANEL_PLAN.md
Normal file
910
CROSS_REFERENCES_PANEL_PLAN.md
Normal file
@@ -0,0 +1,910 @@
|
||||
# Cross-References Panel - Implementation Plan
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement a comprehensive cross-reference system that helps users discover related Scripture passages, understand context, trace themes, and build a deeper knowledge of interconnected Bible teachings.
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🔴 High
|
||||
**Estimated Time:** 2 weeks (80 hours)
|
||||
**Target Completion:** TBD
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals & Objectives
|
||||
|
||||
### Primary Goals
|
||||
1. Display relevant cross-references for any verse
|
||||
2. Provide context and categorization for references
|
||||
3. Enable quick navigation between related passages
|
||||
4. Support custom user-added cross-references
|
||||
5. Visualize reference networks and themes
|
||||
|
||||
### User Value Proposition
|
||||
- **For Bible students**: Understand context and connections
|
||||
- **For teachers**: Prepare comprehensive lessons
|
||||
- **For scholars**: Research thematic progressions
|
||||
- **For new readers**: Discover related teachings
|
||||
- **For memorizers**: Build mental maps of Scripture
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Specifications
|
||||
|
||||
### 1. Cross-Reference Data Model
|
||||
|
||||
```typescript
|
||||
interface CrossReference {
|
||||
id: string
|
||||
fromVerse: VerseReference
|
||||
toVerse: VerseReference
|
||||
type: ReferenceType
|
||||
category: string
|
||||
strength: number // 0-100, relevance score
|
||||
direction: 'forward' | 'backward' | 'bidirectional'
|
||||
source: 'openbible' | 'user' | 'treasury' | 'commentaries'
|
||||
description?: string
|
||||
addedBy?: string // User ID for custom references
|
||||
votes?: number // Community voting on quality
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
interface VerseReference {
|
||||
book: string
|
||||
chapter: number
|
||||
verse: number
|
||||
endVerse?: number // For ranges
|
||||
}
|
||||
|
||||
type ReferenceType =
|
||||
| 'quotation' // Direct quote (OT → NT)
|
||||
| 'allusion' // Indirect reference
|
||||
| 'parallel' // Parallel account (Gospels, Kings/Chronicles)
|
||||
| 'thematic' // Same theme/topic
|
||||
| 'fulfillment' // Prophecy fulfillment
|
||||
| 'contrast' // Contrasting teaching
|
||||
| 'expansion' // Elaboration/explanation
|
||||
| 'application' // Practical application
|
||||
| 'historical' // Historical context
|
||||
| 'wordStudy' // Same Hebrew/Greek word
|
||||
```
|
||||
|
||||
### 2. Cross-Reference Categories
|
||||
|
||||
```typescript
|
||||
const REFERENCE_CATEGORIES = {
|
||||
// Structural
|
||||
'parallel-passages': 'Parallel Passages',
|
||||
'quotations': 'Quotations',
|
||||
'allusions': 'Allusions',
|
||||
|
||||
// Thematic
|
||||
'salvation': 'Salvation',
|
||||
'faith': 'Faith',
|
||||
'love': 'Love',
|
||||
'judgment': 'Judgment',
|
||||
'prophecy': 'Prophecy',
|
||||
'miracles': 'Miracles',
|
||||
'parables': 'Parables',
|
||||
'promises': 'Promises',
|
||||
'commands': 'Commands',
|
||||
'covenants': 'Covenants',
|
||||
|
||||
// Character Studies
|
||||
'christ-prefigured': 'Christ Prefigured',
|
||||
'messianic': 'Messianic References',
|
||||
'holy-spirit': 'Holy Spirit',
|
||||
|
||||
// Literary
|
||||
'poetry': 'Poetic Parallels',
|
||||
'wisdom': 'Wisdom Literature',
|
||||
'apocalyptic': 'Apocalyptic Literature',
|
||||
|
||||
// Historical
|
||||
'chronological': 'Chronological Sequence',
|
||||
'geographical': 'Same Location',
|
||||
|
||||
// Custom
|
||||
'user-defined': 'User Added'
|
||||
}
|
||||
```
|
||||
|
||||
### 3. UI Layout Options
|
||||
|
||||
```
|
||||
Desktop - Sidebar (Default):
|
||||
┌────────────────────────────┬──────────────────┐
|
||||
│ Genesis 1:1-31 │ Cross-References │
|
||||
│ │ │
|
||||
│ 1 In the beginning God │ ▸ Quotations (3) │
|
||||
│ created the heaven and │ • John 1:1-3 │
|
||||
│ the earth. │ • Heb 11:3 │
|
||||
│ │ • Rev 4:11 │
|
||||
│ 2 And the earth was │ │
|
||||
│ without form... │ ▸ Parallel (2) │
|
||||
│ │ • Ps 33:6 │
|
||||
│ │ • Col 1:16 │
|
||||
│ │ │
|
||||
│ [verse 3 selected] │ ▸ Thematic (12) │
|
||||
│ 3 And God said, Let │ • Gen 2:3 │
|
||||
│ there be light: and │ • 2 Cor 4:6 │
|
||||
│ there was light. │ • Jas 1:17 │
|
||||
│ │ + 9 more │
|
||||
└────────────────────────────┴──────────────────┘
|
||||
|
||||
Mobile - Bottom Sheet:
|
||||
┌─────────────────────────┐
|
||||
│ Genesis 1:3 │
|
||||
│ │
|
||||
│ And God said, Let there │
|
||||
│ be light: and there was │
|
||||
│ light. │
|
||||
│ │
|
||||
│ [Tap for references] ▲ │
|
||||
└─────────────────────────┘
|
||||
↓ Swipe up
|
||||
┌─────────────────────────┐
|
||||
│ ≡ Cross-References (17) │
|
||||
├─────────────────────────┤
|
||||
│ Quotations (3) │
|
||||
│ • John 1:1-3 → │
|
||||
│ • Hebrews 11:3 → │
|
||||
│ │
|
||||
│ Thematic (12) │
|
||||
│ • Genesis 2:3 → │
|
||||
│ • 2 Cor 4:6 → │
|
||||
│ + 10 more │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### 4. Collapsible Sidebar Component
|
||||
|
||||
```typescript
|
||||
interface CrossReferencePanelProps {
|
||||
verse: VerseReference | null
|
||||
position: 'left' | 'right' | 'bottom'
|
||||
defaultOpen: boolean
|
||||
width: number // pixels or percentage
|
||||
}
|
||||
|
||||
export const CrossReferencePanel: React.FC<CrossReferencePanelProps> = ({
|
||||
verse,
|
||||
position = 'right',
|
||||
defaultOpen = true,
|
||||
width = 320
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen)
|
||||
const [references, setReferences] = useState<CrossReference[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [groupBy, setGroupBy] = useState<'type' | 'category'>('type')
|
||||
const [sortBy, setSortBy] = useState<'relevance' | 'book' | 'votes'>('relevance')
|
||||
|
||||
useEffect(() => {
|
||||
if (!verse) {
|
||||
setReferences([])
|
||||
return
|
||||
}
|
||||
|
||||
loadReferences(verse)
|
||||
}, [verse])
|
||||
|
||||
const loadReferences = async (verse: VerseReference) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/cross-references?book=${verse.book}&chapter=${verse.chapter}&verse=${verse.verse}`
|
||||
)
|
||||
const data = await response.json()
|
||||
setReferences(data.references)
|
||||
} catch (error) {
|
||||
console.error('Failed to load cross-references:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const groupedReferences = useMemo(() => {
|
||||
if (groupBy === 'type') {
|
||||
return groupByType(references)
|
||||
} else {
|
||||
return groupByCategory(references)
|
||||
}
|
||||
}, [references, groupBy])
|
||||
|
||||
const sortedGroups = useMemo(() => {
|
||||
return Object.entries(groupedReferences).map(([key, refs]) => ({
|
||||
key,
|
||||
references: sortReferences(refs, sortBy)
|
||||
}))
|
||||
}, [groupedReferences, sortBy])
|
||||
|
||||
if (!verse) return null
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor={position}
|
||||
open={isOpen}
|
||||
variant="persistent"
|
||||
sx={{
|
||||
width: isOpen ? width : 0,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width,
|
||||
boxSizing: 'border-box',
|
||||
top: 64, // Below header
|
||||
height: 'calc(100% - 64px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h6">
|
||||
Cross-References
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setIsOpen(false)}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{verse.book} {verse.chapter}:{verse.verse}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Controls */}
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Box display="flex" gap={1} mb={1}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Group By</InputLabel>
|
||||
<Select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value as any)}
|
||||
label="Group By"
|
||||
>
|
||||
<MenuItem value="type">Type</MenuItem>
|
||||
<MenuItem value="category">Category</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Sort By</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
label="Sort By"
|
||||
>
|
||||
<MenuItem value="relevance">Relevance</MenuItem>
|
||||
<MenuItem value="book">Book Order</MenuItem>
|
||||
<MenuItem value="votes">Most Voted</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" gap={1}>
|
||||
<Button size="small" variant="outlined" fullWidth>
|
||||
<AddIcon /> Add Reference
|
||||
</Button>
|
||||
<IconButton size="small">
|
||||
<FilterListIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* References List */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={3}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : references.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
No cross-references found for this verse.
|
||||
</Alert>
|
||||
) : (
|
||||
sortedGroups.map(group => (
|
||||
<ReferenceGroup
|
||||
key={group.key}
|
||||
title={group.key}
|
||||
references={group.references}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Reference Group Component
|
||||
|
||||
```typescript
|
||||
interface ReferenceGroupProps {
|
||||
title: string
|
||||
references: CrossReference[]
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
const ReferenceGroup: React.FC<ReferenceGroupProps> = ({
|
||||
title,
|
||||
references,
|
||||
defaultExpanded = true
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
const [previewVerse, setPreviewVerse] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Box mb={2}>
|
||||
<Box
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
'&:hover': { bgcolor: 'action.hover' }
|
||||
}}
|
||||
>
|
||||
<IconButton size="small">
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
<Typography variant="subtitle2" fontWeight="600">
|
||||
{title} ({references.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<List dense>
|
||||
{references.map(ref => (
|
||||
<ReferenceItem
|
||||
key={ref.id}
|
||||
reference={ref}
|
||||
onHover={setPreviewVerse}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
|
||||
{/* Preview popover */}
|
||||
{previewVerse && (
|
||||
<VersePreviewPopover
|
||||
verseText={previewVerse}
|
||||
onClose={() => setPreviewVerse(null)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Reference Item with Preview
|
||||
|
||||
```typescript
|
||||
interface ReferenceItemProps {
|
||||
reference: CrossReference
|
||||
onHover: (verseText: string | null) => void
|
||||
}
|
||||
|
||||
const ReferenceItem: React.FC<ReferenceItemProps> = ({
|
||||
reference,
|
||||
onHover
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [verseText, setVerseText] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleMouseEnter = async () => {
|
||||
if (verseText) {
|
||||
onHover(verseText)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/bible/verses?` +
|
||||
`book=${reference.toVerse.book}&` +
|
||||
`chapter=${reference.toVerse.chapter}&` +
|
||||
`verse=${reference.toVerse.verse}`
|
||||
)
|
||||
const data = await response.json()
|
||||
const text = data.verses[0]?.text || ''
|
||||
setVerseText(text)
|
||||
onHover(text)
|
||||
} catch (error) {
|
||||
console.error('Failed to load verse preview:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
const { book, chapter, verse } = reference.toVerse
|
||||
router.push(`/bible/${book.toLowerCase()}/${chapter}#verse-${verse}`)
|
||||
}
|
||||
|
||||
const formatReference = (ref: VerseReference): string => {
|
||||
const baseRef = `${ref.book} ${ref.chapter}:${ref.verse}`
|
||||
return ref.endVerse ? `${baseRef}-${ref.endVerse}` : baseRef
|
||||
}
|
||||
|
||||
const getTypeIcon = (type: ReferenceType) => {
|
||||
const icons = {
|
||||
quotation: <FormatQuoteIcon fontSize="small" />,
|
||||
parallel: <CompareArrowsIcon fontSize="small" />,
|
||||
thematic: <CategoryIcon fontSize="small" />,
|
||||
fulfillment: <CheckCircleIcon fontSize="small" />,
|
||||
allusion: <LinkIcon fontSize="small" />,
|
||||
// ... more mappings
|
||||
}
|
||||
return icons[type] || <ArticleIcon fontSize="small" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
button
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
{getTypeIcon(reference.type)}
|
||||
</ListItemIcon>
|
||||
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography variant="body2" fontWeight="500">
|
||||
{formatReference(reference.toVerse)}
|
||||
</Typography>
|
||||
{reference.strength >= 80 && (
|
||||
<Chip label="High" size="small" color="success" />
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={reference.description}
|
||||
/>
|
||||
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton size="small" edge="end">
|
||||
<ArrowForwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Visual Indicators in Text
|
||||
|
||||
```typescript
|
||||
// Add superscript indicators in verse text
|
||||
const VerseWithReferences: React.FC<{
|
||||
verse: BibleVerse
|
||||
references: CrossReference[]
|
||||
}> = ({ verse, references }) => {
|
||||
const hasReferences = references.length > 0
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="verse"
|
||||
data-verse={verse.verseNum}
|
||||
sx={{ position: 'relative' }}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
className="verse-number"
|
||||
sx={{ mr: 1, fontWeight: 600, color: 'text.secondary' }}
|
||||
>
|
||||
{verse.verseNum}
|
||||
</Typography>
|
||||
|
||||
<Typography component="span" className="verse-text">
|
||||
{verse.text}
|
||||
</Typography>
|
||||
|
||||
{hasReferences && (
|
||||
<Tooltip title={`${references.length} cross-references`}>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
width: 20,
|
||||
height: 20,
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
<Badge badgeContent={references.length} color="primary">
|
||||
<LinkIcon fontSize="inherit" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Add Custom Cross-Reference
|
||||
|
||||
```typescript
|
||||
interface AddReferenceDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
fromVerse: VerseReference
|
||||
}
|
||||
|
||||
const AddReferenceDialog: React.FC<AddReferenceDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fromVerse
|
||||
}) => {
|
||||
const [toVerse, setToVerse] = useState<VerseReference | null>(null)
|
||||
const [type, setType] = useState<ReferenceType>('thematic')
|
||||
const [category, setCategory] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!toVerse) return
|
||||
|
||||
try {
|
||||
await fetch('/api/cross-references', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fromVerse,
|
||||
toVerse,
|
||||
type,
|
||||
category,
|
||||
description,
|
||||
source: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to add cross-reference:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Add Cross-Reference</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
From: {fromVerse.book} {fromVerse.chapter}:{fromVerse.verse}
|
||||
</Typography>
|
||||
|
||||
<VerseSelector
|
||||
label="To Verse"
|
||||
value={toVerse}
|
||||
onChange={setToVerse}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select value={type} onChange={(e) => setType(e.target.value as ReferenceType)}>
|
||||
<MenuItem value="quotation">Quotation</MenuItem>
|
||||
<MenuItem value="parallel">Parallel</MenuItem>
|
||||
<MenuItem value="thematic">Thematic</MenuItem>
|
||||
<MenuItem value="allusion">Allusion</MenuItem>
|
||||
<MenuItem value="fulfillment">Fulfillment</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} variant="contained">
|
||||
Add Reference
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Bidirectional Linking
|
||||
|
||||
```typescript
|
||||
// Automatically create reverse references
|
||||
const createBidirectionalReference = async (
|
||||
fromVerse: VerseReference,
|
||||
toVerse: VerseReference,
|
||||
type: ReferenceType
|
||||
) => {
|
||||
// Create forward reference
|
||||
await createReference({
|
||||
fromVerse,
|
||||
toVerse,
|
||||
type,
|
||||
direction: 'forward'
|
||||
})
|
||||
|
||||
// Create backward reference automatically
|
||||
await createReference({
|
||||
fromVerse: toVerse,
|
||||
toVerse: fromVerse,
|
||||
type,
|
||||
direction: 'backward'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Search Cross-References
|
||||
|
||||
```typescript
|
||||
interface ReferenceSearchProps {
|
||||
onSelect: (reference: CrossReference) => void
|
||||
}
|
||||
|
||||
const ReferenceSearch: React.FC<ReferenceSearchProps> = ({ onSelect }) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<CrossReference[]>([])
|
||||
|
||||
const handleSearch = useDebounce(async (searchQuery: string) => {
|
||||
if (searchQuery.length < 3) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/cross-references/search?q=${encodeURIComponent(searchQuery)}`
|
||||
)
|
||||
const data = await response.json()
|
||||
setResults(data.references)
|
||||
}, 300)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
placeholder="Search references by verse, theme, or keyword..."
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
handleSearch(e.target.value)
|
||||
}}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: <SearchIcon />
|
||||
}}
|
||||
/>
|
||||
|
||||
<List>
|
||||
{results.map(ref => (
|
||||
<ListItem
|
||||
key={ref.id}
|
||||
button
|
||||
onClick={() => onSelect(ref)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={formatReference(ref.fromVerse)}
|
||||
secondary={ref.description}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
```prisma
|
||||
model CrossReference {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// From verse
|
||||
fromBook String
|
||||
fromChapter Int
|
||||
fromVerse Int
|
||||
fromEndVerse Int?
|
||||
|
||||
// To verse
|
||||
toBook String
|
||||
toChapter Int
|
||||
toVerse Int
|
||||
toEndVerse Int?
|
||||
|
||||
// Metadata
|
||||
type String // ReferenceType enum
|
||||
category String?
|
||||
strength Int @default(50) // 0-100
|
||||
direction String @default("bidirectional")
|
||||
source String @default("openbible") // openbible, user, treasury
|
||||
description String?
|
||||
|
||||
// User tracking (for custom references)
|
||||
addedBy String?
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
// Community features
|
||||
votes Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([fromBook, fromChapter, fromVerse])
|
||||
@@index([toBook, toChapter, toVerse])
|
||||
@@index([type, category])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ReferenceVote {
|
||||
id String @id @default(cuid())
|
||||
referenceId String
|
||||
userId String
|
||||
value Int // +1 or -1
|
||||
|
||||
reference CrossReference @relation(fields: [referenceId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([referenceId, userId])
|
||||
@@index([referenceId])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
```typescript
|
||||
// Get cross-references for a verse
|
||||
GET /api/cross-references
|
||||
Query params:
|
||||
- book: string
|
||||
- chapter: number
|
||||
- verse: number
|
||||
- type?: ReferenceType[]
|
||||
- category?: string[]
|
||||
- minStrength?: number (0-100)
|
||||
Response: {
|
||||
references: CrossReference[]
|
||||
count: number
|
||||
}
|
||||
|
||||
// Add custom cross-reference
|
||||
POST /api/cross-references
|
||||
Body: {
|
||||
fromVerse: VerseReference
|
||||
toVerse: VerseReference
|
||||
type: ReferenceType
|
||||
category?: string
|
||||
description?: string
|
||||
}
|
||||
Response: {
|
||||
success: boolean
|
||||
reference: CrossReference
|
||||
}
|
||||
|
||||
// Vote on reference quality
|
||||
POST /api/cross-references/:id/vote
|
||||
Body: { value: 1 | -1 }
|
||||
|
||||
// Search cross-references
|
||||
GET /api/cross-references/search
|
||||
Query: q=keyword
|
||||
Response: { references: CrossReference[] }
|
||||
|
||||
// Bulk import cross-references (admin)
|
||||
POST /api/admin/cross-references/import
|
||||
Body: { references: CrossReference[], source: string }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Timeline
|
||||
|
||||
### Week 1: Foundation & Data
|
||||
**Day 1-2: Database & Data Import**
|
||||
- [ ] Create database schema
|
||||
- [ ] Import OpenBible.info dataset (~65,000 references)
|
||||
- [ ] Build API endpoints
|
||||
- [ ] Test data queries
|
||||
|
||||
**Day 3-4: UI Components**
|
||||
- [ ] Create sidebar component
|
||||
- [ ] Build reference list UI
|
||||
- [ ] Implement grouping/sorting
|
||||
- [ ] Add loading states
|
||||
|
||||
**Day 5: Navigation & Preview**
|
||||
- [ ] Implement click navigation
|
||||
- [ ] Build hover preview
|
||||
- [ ] Add verse indicators
|
||||
- [ ] Test UX flow
|
||||
|
||||
**Deliverable:** Working cross-reference viewer
|
||||
|
||||
### Week 2: Advanced Features
|
||||
**Day 1-2: Custom References**
|
||||
- [ ] Build add reference dialog
|
||||
- [ ] Implement bidirectional linking
|
||||
- [ ] Add edit/delete functionality
|
||||
- [ ] Test CRUD operations
|
||||
|
||||
**Day 3-4: Search & Filter**
|
||||
- [ ] Implement search
|
||||
- [ ] Add advanced filters
|
||||
- [ ] Build category browser
|
||||
- [ ] Add sorting options
|
||||
|
||||
**Day 5: Polish & Mobile**
|
||||
- [ ] Optimize mobile layout
|
||||
- [ ] Performance tuning
|
||||
- [ ] Bug fixes
|
||||
- [ ] Documentation
|
||||
|
||||
**Deliverable:** Production-ready cross-reference system
|
||||
|
||||
---
|
||||
|
||||
## 📚 Data Sources
|
||||
|
||||
### OpenBible.info Cross-Reference Dataset
|
||||
- **URL**: https://openbible.info/labs/cross-references/
|
||||
- **Size**: ~340,000 cross-references
|
||||
- **License**: CC BY 4.0
|
||||
- **Coverage**: Old & New Testament
|
||||
- **Format**: CSV/JSON
|
||||
|
||||
### Treasury of Scripture Knowledge
|
||||
- **Coverage**: Extensive OT/NT references
|
||||
- **Public Domain**: Yes
|
||||
- **Quality**: High (curated by scholars)
|
||||
|
||||
### User-Generated References
|
||||
- Allow community contributions
|
||||
- Implement voting/quality system
|
||||
- Moderate for accuracy
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Plan
|
||||
|
||||
### Pre-Launch
|
||||
- [ ] Import cross-reference dataset
|
||||
- [ ] Test with 1000+ verses
|
||||
- [ ] Performance optimization
|
||||
- [ ] Mobile testing
|
||||
- [ ] Accessibility audit
|
||||
|
||||
### Rollout
|
||||
1. **Beta**: 10% users, collect feedback
|
||||
2. **Staged**: 50% users
|
||||
3. **Full**: 100% deployment
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Owner:** Development Team
|
||||
**Status:** Ready for Implementation
|
||||
800
CUSTOM_FONTS_DYSLEXIA_SUPPORT_PLAN.md
Normal file
800
CUSTOM_FONTS_DYSLEXIA_SUPPORT_PLAN.md
Normal file
@@ -0,0 +1,800 @@
|
||||
# Custom Fonts & Dyslexia Support - Implementation Plan
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement comprehensive font customization and dyslexia-friendly features to improve readability for all users, with special accommodations for those with reading difficulties or visual processing challenges.
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🟡 Medium
|
||||
**Estimated Time:** 1 week (40 hours)
|
||||
**Target Completion:** TBD
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals & Objectives
|
||||
|
||||
### Primary Goals
|
||||
1. Provide extensive font customization options
|
||||
2. Integrate dyslexia-friendly fonts and features
|
||||
3. Enable color overlay filters for visual comfort
|
||||
4. Support custom font uploads
|
||||
5. Offer letter/word spacing adjustments
|
||||
|
||||
### User Value Proposition
|
||||
- **For dyslexic readers**: Specialized fonts and spacing
|
||||
- **For visually impaired**: High contrast and large text options
|
||||
- **For personal preference**: Complete customization
|
||||
- **For comfort**: Reduce eye strain
|
||||
- **For accessibility**: WCAG AAA compliance
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Specifications
|
||||
|
||||
### 1. Font Configuration
|
||||
|
||||
```typescript
|
||||
interface FontConfig {
|
||||
// Font Selection
|
||||
fontFamily: string
|
||||
customFontUrl?: string // For uploaded fonts
|
||||
|
||||
// Size
|
||||
fontSize: number // 12-32px
|
||||
fontSizePreset: 'small' | 'medium' | 'large' | 'extra-large' | 'custom'
|
||||
|
||||
// Weight & Style
|
||||
fontWeight: number // 300-900
|
||||
fontStyle: 'normal' | 'italic'
|
||||
|
||||
// Spacing
|
||||
letterSpacing: number // -2 to 10px
|
||||
wordSpacing: number // -5 to 20px
|
||||
lineHeight: number // 1.0 - 3.0
|
||||
paragraphSpacing: number // 0-40px
|
||||
|
||||
// Dyslexia Features
|
||||
isDyslexiaMode: boolean
|
||||
dyslexiaFontSize: number // Usually 14-18pt for dyslexia
|
||||
dyslexiaSpacing: 'normal' | 'wide' | 'extra-wide'
|
||||
boldFirstLetters: boolean // Bionic reading style
|
||||
|
||||
// Visual Aids
|
||||
colorOverlay: string | null // Tinted overlay
|
||||
overlayOpacity: number // 0-100%
|
||||
highContrast: boolean
|
||||
underlineLinks: boolean
|
||||
|
||||
// Advanced
|
||||
textTransform: 'none' | 'uppercase' | 'lowercase' | 'capitalize'
|
||||
textDecoration: 'none' | 'underline' | 'overline'
|
||||
}
|
||||
|
||||
// Available font families
|
||||
const FONT_FAMILIES = {
|
||||
standard: [
|
||||
{ name: 'System Default', value: 'system-ui, -apple-system' },
|
||||
{ name: 'Arial', value: 'Arial, sans-serif' },
|
||||
{ name: 'Georgia', value: 'Georgia, serif' },
|
||||
{ name: 'Times New Roman', value: '"Times New Roman", serif' },
|
||||
{ name: 'Verdana', value: 'Verdana, sans-serif' },
|
||||
{ name: 'Courier New', value: '"Courier New", monospace' }
|
||||
],
|
||||
|
||||
readable: [
|
||||
{ name: 'Open Sans', value: '"Open Sans", sans-serif' },
|
||||
{ name: 'Lora', value: 'Lora, serif' },
|
||||
{ name: 'Merriweather', value: 'Merriweather, serif' },
|
||||
{ name: 'Roboto', value: 'Roboto, sans-serif' },
|
||||
{ name: 'Source Sans Pro', value: '"Source Sans Pro", sans-serif' }
|
||||
],
|
||||
|
||||
dyslexiaFriendly: [
|
||||
{
|
||||
name: 'OpenDyslexic',
|
||||
value: 'OpenDyslexic, sans-serif',
|
||||
url: '/fonts/OpenDyslexic-Regular.woff2',
|
||||
description: 'Specially designed with weighted bottoms to prevent letter rotation'
|
||||
},
|
||||
{
|
||||
name: 'Lexend',
|
||||
value: 'Lexend, sans-serif',
|
||||
url: 'https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700',
|
||||
description: 'Variable font designed to reduce visual stress'
|
||||
},
|
||||
{
|
||||
name: 'Comic Sans MS',
|
||||
value: '"Comic Sans MS", cursive',
|
||||
description: 'Often recommended for dyslexia due to unique letter shapes'
|
||||
},
|
||||
{
|
||||
name: 'Dyslexie',
|
||||
value: 'Dyslexie, sans-serif',
|
||||
url: '/fonts/Dyslexie-Regular.woff2',
|
||||
description: 'Premium font designed by a dyslexic designer',
|
||||
isPremium: true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Font Selector Component
|
||||
|
||||
```typescript
|
||||
const FontSelector: React.FC<{
|
||||
config: FontConfig
|
||||
onChange: (config: Partial<FontConfig>) => void
|
||||
}> = ({ config, onChange }) => {
|
||||
const [activeCategory, setActiveCategory] = useState<'standard' | 'readable' | 'dyslexiaFriendly'>('standard')
|
||||
const [previewText, setPreviewText] = useState('In the beginning God created the heaven and the earth.')
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Font Selection
|
||||
</Typography>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<Tabs value={activeCategory} onChange={(_, v) => setActiveCategory(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Standard" value="standard" />
|
||||
<Tab label="Readable" value="readable" />
|
||||
<Tab label="Dyslexia-Friendly" value="dyslexiaFriendly" />
|
||||
</Tabs>
|
||||
|
||||
{/* Font List */}
|
||||
<List>
|
||||
{FONT_FAMILIES[activeCategory].map(font => (
|
||||
<ListItem
|
||||
key={font.value}
|
||||
button
|
||||
selected={config.fontFamily === font.value}
|
||||
onClick={() => onChange({ fontFamily: font.value })}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography style={{ fontFamily: font.value }}>
|
||||
{font.name}
|
||||
</Typography>
|
||||
{font.isPremium && (
|
||||
<Chip label="Premium" size="small" color="primary" />
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={font.description}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton onClick={() => loadFontPreview(font)}>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* Upload Custom Font */}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<UploadIcon />}
|
||||
onClick={() => uploadCustomFont()}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Upload Custom Font
|
||||
</Button>
|
||||
|
||||
{/* Preview */}
|
||||
<Paper sx={{ p: 2, mt: 3, bgcolor: 'background.default' }}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
Preview
|
||||
</Typography>
|
||||
<Typography
|
||||
style={{
|
||||
fontFamily: config.fontFamily,
|
||||
fontSize: `${config.fontSize}px`,
|
||||
letterSpacing: `${config.letterSpacing}px`,
|
||||
wordSpacing: `${config.wordSpacing}px`,
|
||||
lineHeight: config.lineHeight
|
||||
}}
|
||||
>
|
||||
{previewText}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Font Size & Spacing Controls
|
||||
|
||||
```typescript
|
||||
const FontSizeControls: React.FC<{
|
||||
config: FontConfig
|
||||
onChange: (config: Partial<FontConfig>) => void
|
||||
}> = ({ config, onChange }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Size & Spacing
|
||||
</Typography>
|
||||
|
||||
{/* Font Size Presets */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Quick Presets
|
||||
</Typography>
|
||||
<ButtonGroup fullWidth>
|
||||
<Button
|
||||
variant={config.fontSizePreset === 'small' ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange({ fontSizePreset: 'small', fontSize: 14 })}
|
||||
>
|
||||
Small
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.fontSizePreset === 'medium' ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange({ fontSizePreset: 'medium', fontSize: 16 })}
|
||||
>
|
||||
Medium
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.fontSizePreset === 'large' ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange({ fontSizePreset: 'large', fontSize: 20 })}
|
||||
>
|
||||
Large
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.fontSizePreset === 'extra-large' ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange({ fontSizePreset: 'extra-large', fontSize: 24 })}
|
||||
>
|
||||
Extra Large
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Custom Font Size */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Font Size: {config.fontSize}px
|
||||
</Typography>
|
||||
<Slider
|
||||
value={config.fontSize}
|
||||
onChange={(_, value) => onChange({ fontSize: value as number, fontSizePreset: 'custom' })}
|
||||
min={12}
|
||||
max={32}
|
||||
step={1}
|
||||
marks={[
|
||||
{ value: 12, label: '12' },
|
||||
{ value: 16, label: '16' },
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 24, label: '24' },
|
||||
{ value: 32, label: '32' }
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Letter Spacing */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Letter Spacing: {config.letterSpacing}px
|
||||
</Typography>
|
||||
<Slider
|
||||
value={config.letterSpacing}
|
||||
onChange={(_, value) => onChange({ letterSpacing: value as number })}
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.5}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Word Spacing */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Word Spacing: {config.wordSpacing}px
|
||||
</Typography>
|
||||
<Slider
|
||||
value={config.wordSpacing}
|
||||
onChange={(_, value) => onChange({ wordSpacing: value as number })}
|
||||
min={-5}
|
||||
max={20}
|
||||
step={1}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Line Height */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Line Height: {config.lineHeight}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={config.lineHeight}
|
||||
onChange={(_, value) => onChange({ lineHeight: value as number })}
|
||||
min={1.0}
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
marks={[
|
||||
{ value: 1.0, label: '1.0' },
|
||||
{ value: 1.5, label: '1.5' },
|
||||
{ value: 2.0, label: '2.0' },
|
||||
{ value: 2.5, label: '2.5' },
|
||||
{ value: 3.0, label: '3.0' }
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Font Weight */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Font Weight: {config.fontWeight}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={config.fontWeight}
|
||||
onChange={(_, value) => onChange({ fontWeight: value as number })}
|
||||
min={300}
|
||||
max={900}
|
||||
step={100}
|
||||
marks={[
|
||||
{ value: 300, label: 'Light' },
|
||||
{ value: 400, label: 'Normal' },
|
||||
{ value: 700, label: 'Bold' },
|
||||
{ value: 900, label: 'Black' }
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Dyslexia Mode Settings
|
||||
|
||||
```typescript
|
||||
const DyslexiaSettings: React.FC<{
|
||||
config: FontConfig
|
||||
onChange: (config: Partial<FontConfig>) => void
|
||||
}> = ({ config, onChange }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Dyslexia Support
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
These settings are optimized for readers with dyslexia and reading difficulties.
|
||||
</Alert>
|
||||
|
||||
{/* Enable Dyslexia Mode */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.isDyslexiaMode}
|
||||
onChange={(e) => {
|
||||
const enabled = e.target.checked
|
||||
onChange({
|
||||
isDyslexiaMode: enabled,
|
||||
...(enabled && {
|
||||
fontFamily: 'OpenDyslexic, sans-serif',
|
||||
fontSize: 16,
|
||||
letterSpacing: 1,
|
||||
wordSpacing: 3,
|
||||
lineHeight: 1.8
|
||||
})
|
||||
})
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Enable Dyslexia Mode"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{config.isDyslexiaMode && (
|
||||
<>
|
||||
{/* Spacing Presets */}
|
||||
<FormControl fullWidth sx={{ mb: 3 }}>
|
||||
<InputLabel>Spacing</InputLabel>
|
||||
<Select
|
||||
value={config.dyslexiaSpacing}
|
||||
onChange={(e) => {
|
||||
const spacing = e.target.value
|
||||
let letterSpacing = 0
|
||||
let wordSpacing = 0
|
||||
|
||||
if (spacing === 'wide') {
|
||||
letterSpacing = 1.5
|
||||
wordSpacing = 4
|
||||
} else if (spacing === 'extra-wide') {
|
||||
letterSpacing = 2.5
|
||||
wordSpacing = 6
|
||||
}
|
||||
|
||||
onChange({
|
||||
dyslexiaSpacing: spacing as any,
|
||||
letterSpacing,
|
||||
wordSpacing
|
||||
})
|
||||
}}
|
||||
>
|
||||
<MenuItem value="normal">Normal</MenuItem>
|
||||
<MenuItem value="wide">Wide (Recommended)</MenuItem>
|
||||
<MenuItem value="extra-wide">Extra Wide</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Bold First Letters */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.boldFirstLetters}
|
||||
onChange={(e) => onChange({ boldFirstLetters: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography>Bold First Letters (Bionic Reading)</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Makes the first part of each word bold to guide eye movement
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* High Contrast */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.highContrast}
|
||||
onChange={(e) => onChange({ highContrast: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="High Contrast Mode"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* Underline Links */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.underlineLinks}
|
||||
onChange={(e) => onChange({ underlineLinks: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label="Underline All Links"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Color Overlay Filters
|
||||
|
||||
```typescript
|
||||
const ColorOverlaySettings: React.FC<{
|
||||
config: FontConfig
|
||||
onChange: (config: Partial<FontConfig>) => void
|
||||
}> = ({ config, onChange }) => {
|
||||
const overlayColors = [
|
||||
{ name: 'None', color: null },
|
||||
{ name: 'Yellow', color: '#FFEB3B', description: 'Reduces glare' },
|
||||
{ name: 'Blue', color: '#2196F3', description: 'Calming effect' },
|
||||
{ name: 'Green', color: '#4CAF50', description: 'Eye comfort' },
|
||||
{ name: 'Pink', color: '#E91E63', description: 'Reduces contrast' },
|
||||
{ name: 'Orange', color: '#FF9800', description: 'Warm tint' },
|
||||
{ name: 'Purple', color: '#9C27B0', description: 'Reduces brightness' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Color Overlay
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
Color overlays can help reduce visual stress and improve reading comfort.
|
||||
</Alert>
|
||||
|
||||
{/* Overlay Color Selection */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{overlayColors.map(overlay => (
|
||||
<Grid item xs={6} sm={4} key={overlay.name}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
border: 2,
|
||||
borderColor: config.colorOverlay === overlay.color ? 'primary.main' : 'transparent',
|
||||
bgcolor: overlay.color || 'background.paper',
|
||||
'&:hover': { boxShadow: 4 }
|
||||
}}
|
||||
onClick={() => onChange({ colorOverlay: overlay.color })}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight="600">
|
||||
{overlay.name}
|
||||
</Typography>
|
||||
{overlay.description && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{overlay.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Opacity Control */}
|
||||
{config.colorOverlay && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Overlay Opacity: {config.overlayOpacity}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={config.overlayOpacity}
|
||||
onChange={(_, value) => onChange({ overlayOpacity: value as number })}
|
||||
min={10}
|
||||
max={100}
|
||||
step={5}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mt: 3,
|
||||
position: 'relative',
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
{config.colorOverlay && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bgcolor: config.colorOverlay,
|
||||
opacity: config.overlayOpacity / 100,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
Preview with overlay
|
||||
</Typography>
|
||||
<Typography>
|
||||
The quick brown fox jumps over the lazy dog. In the beginning God created the heaven and the earth.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Custom Font Upload
|
||||
|
||||
```typescript
|
||||
const CustomFontUpload: React.FC<{
|
||||
onUpload: (fontUrl: string, fontName: string) => void
|
||||
}> = ({ onUpload }) => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [fontName, setFontName] = useState('')
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['.woff', '.woff2', '.ttf', '.otf']
|
||||
const fileExt = file.name.substring(file.name.lastIndexOf('.')).toLowerCase()
|
||||
|
||||
if (!validTypes.includes(fileExt)) {
|
||||
alert('Please upload a valid font file (.woff, .woff2, .ttf, .otf)')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
|
||||
try {
|
||||
// Upload to server or cloud storage
|
||||
const formData = new FormData()
|
||||
formData.append('font', file)
|
||||
formData.append('name', fontName || file.name)
|
||||
|
||||
const response = await fetch('/api/fonts/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
onUpload(data.fontUrl, data.fontName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Font upload failed:', error)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onClose={() => {}}>
|
||||
<DialogTitle>Upload Custom Font</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<TextField
|
||||
label="Font Name"
|
||||
value={fontName}
|
||||
onChange={(e) => setFontName(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
component="label"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={<UploadIcon />}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Select Font File'}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".woff,.woff2,.ttf,.otf"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Supported formats: WOFF, WOFF2, TTF, OTF
|
||||
</Alert>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Apply Font Configuration
|
||||
|
||||
```typescript
|
||||
// Apply configuration to reader
|
||||
const applyFontConfig = (config: FontConfig) => {
|
||||
const readerElement = document.querySelector('.bible-reader-content')
|
||||
|
||||
if (!readerElement) return
|
||||
|
||||
const styles = {
|
||||
fontFamily: config.fontFamily,
|
||||
fontSize: `${config.fontSize}px`,
|
||||
fontWeight: config.fontWeight,
|
||||
fontStyle: config.fontStyle,
|
||||
letterSpacing: `${config.letterSpacing}px`,
|
||||
wordSpacing: `${config.wordSpacing}px`,
|
||||
lineHeight: config.lineHeight,
|
||||
textTransform: config.textTransform,
|
||||
textDecoration: config.textDecoration
|
||||
}
|
||||
|
||||
Object.assign(readerElement.style, styles)
|
||||
|
||||
// Apply high contrast
|
||||
if (config.highContrast) {
|
||||
readerElement.classList.add('high-contrast')
|
||||
} else {
|
||||
readerElement.classList.remove('high-contrast')
|
||||
}
|
||||
|
||||
// Apply color overlay
|
||||
if (config.colorOverlay) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'color-overlay'
|
||||
overlay.style.backgroundColor = config.colorOverlay
|
||||
overlay.style.opacity = (config.overlayOpacity / 100).toString()
|
||||
readerElement.prepend(overlay)
|
||||
}
|
||||
}
|
||||
|
||||
// CSS for high contrast mode
|
||||
const highContrastStyles = `
|
||||
.high-contrast {
|
||||
background-color: #000 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.high-contrast .verse-number {
|
||||
color: #ffeb3b !important;
|
||||
}
|
||||
|
||||
.high-contrast a {
|
||||
color: #00bcd4 !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
```prisma
|
||||
model FontPreference {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
fontFamily String @default("system-ui")
|
||||
customFontUrl String?
|
||||
fontSize Int @default(16)
|
||||
fontWeight Int @default(400)
|
||||
letterSpacing Float @default(0)
|
||||
wordSpacing Float @default(0)
|
||||
lineHeight Float @default(1.6)
|
||||
|
||||
isDyslexiaMode Boolean @default(false)
|
||||
dyslexiaSpacing String @default("normal")
|
||||
boldFirstLetters Boolean @default(false)
|
||||
|
||||
colorOverlay String?
|
||||
overlayOpacity Int @default(30)
|
||||
highContrast Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model CustomFont {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
name String
|
||||
url String
|
||||
format String // woff, woff2, ttf, otf
|
||||
fileSize Int
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Timeline
|
||||
|
||||
### Week 1
|
||||
**Day 1-2:** Foundation
|
||||
- [ ] Font selector component
|
||||
- [ ] Size/spacing controls
|
||||
- [ ] Preview functionality
|
||||
|
||||
**Day 3:** Dyslexia Features
|
||||
- [ ] Dyslexia mode settings
|
||||
- [ ] OpenDyslexic/Lexend integration
|
||||
- [ ] Bionic reading formatter
|
||||
|
||||
**Day 4:** Visual Aids
|
||||
- [ ] Color overlay system
|
||||
- [ ] High contrast mode
|
||||
- [ ] Accessibility testing
|
||||
|
||||
**Day 5:** Polish & Testing
|
||||
- [ ] Custom font upload
|
||||
- [ ] Performance optimization
|
||||
- [ ] Cross-browser testing
|
||||
- [ ] Documentation
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Status:** Ready for Implementation
|
||||
883
EXPORT_FUNCTIONALITY_PLAN.md
Normal file
883
EXPORT_FUNCTIONALITY_PLAN.md
Normal file
@@ -0,0 +1,883 @@
|
||||
# Export Functionality - Implementation Plan
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement comprehensive export capabilities allowing users to download Bible passages, study notes, highlights, and annotations in multiple formats for offline study, sharing, and printing.
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🔴 High
|
||||
**Estimated Time:** 2-3 weeks (80-120 hours)
|
||||
**Target Completion:** TBD
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals & Objectives
|
||||
|
||||
### Primary Goals
|
||||
1. Export Bible passages in multiple formats (PDF, DOCX, Markdown, TXT)
|
||||
2. Include user highlights and notes in exports
|
||||
3. Provide print-optimized layouts
|
||||
4. Support batch exports (multiple chapters/books)
|
||||
5. Enable customization of export appearance
|
||||
|
||||
### User Value Proposition
|
||||
- **For students**: Create study materials for offline use
|
||||
- **For teachers**: Prepare handouts and lesson materials
|
||||
- **For preachers**: Print sermon references
|
||||
- **For small groups**: Share study guides
|
||||
- **For archiving**: Backup personal annotations
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Specifications
|
||||
|
||||
### 1. Export Formats
|
||||
|
||||
```typescript
|
||||
type ExportFormat = 'pdf' | 'docx' | 'markdown' | 'txt' | 'epub' | 'json'
|
||||
|
||||
interface ExportConfig {
|
||||
// Format
|
||||
format: ExportFormat
|
||||
|
||||
// Content selection
|
||||
book: string
|
||||
startChapter: number
|
||||
endChapter: number
|
||||
startVerse?: number
|
||||
endVerse?: number
|
||||
includeHeadings: boolean
|
||||
includeVerseNumbers: boolean
|
||||
includeChapterNumbers: boolean
|
||||
|
||||
// User content
|
||||
includeHighlights: boolean
|
||||
includeNotes: boolean
|
||||
includeBookmarks: boolean
|
||||
notesPosition: 'inline' | 'footnotes' | 'endnotes' | 'separate'
|
||||
|
||||
// Appearance
|
||||
fontSize: number // 10-16pt
|
||||
fontFamily: string
|
||||
lineHeight: number // 1.0-2.0
|
||||
pageSize: 'A4' | 'Letter' | 'Legal'
|
||||
margins: { top: number; right: number; bottom: number; left: number }
|
||||
columns: 1 | 2
|
||||
|
||||
// Header/Footer
|
||||
includeHeader: boolean
|
||||
headerText: string
|
||||
includeFooter: boolean
|
||||
footerText: string
|
||||
includePageNumbers: boolean
|
||||
|
||||
// Metadata
|
||||
includeTableOfContents: boolean
|
||||
includeCoverPage: boolean
|
||||
coverTitle: string
|
||||
coverSubtitle: string
|
||||
author: string
|
||||
date: string
|
||||
|
||||
// Advanced
|
||||
versionComparison: string[] // Multiple version IDs for parallel
|
||||
colorMode: 'color' | 'grayscale' | 'print'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Export Dialog UI
|
||||
|
||||
```typescript
|
||||
const ExportDialog: React.FC<{
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
defaultSelection?: {
|
||||
book: string
|
||||
chapter: number
|
||||
}
|
||||
}> = ({ open, onClose, defaultSelection }) => {
|
||||
const [config, setConfig] = useState<ExportConfig>(getDefaultConfig())
|
||||
const [estimatedSize, setEstimatedSize] = useState<string>('0 KB')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
// Calculate estimated file size
|
||||
useEffect(() => {
|
||||
const estimate = calculateEstimatedSize(config)
|
||||
setEstimatedSize(estimate)
|
||||
}, [config])
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
setProgress(0)
|
||||
|
||||
try {
|
||||
const result = await exportContent(config, (percent) => {
|
||||
setProgress(percent)
|
||||
})
|
||||
|
||||
// Trigger download
|
||||
downloadFile(result.blob, result.filename)
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
// Show error to user
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
Export Bible Content
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tab label="Content" />
|
||||
<Tab label="Format" />
|
||||
<Tab label="Layout" />
|
||||
<Tab label="Advanced" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{activeTab === 0 && <ContentSelectionTab config={config} onChange={setConfig} />}
|
||||
{activeTab === 1 && <FormatOptionsTab config={config} onChange={setConfig} />}
|
||||
{activeTab === 2 && <LayoutSettingsTab config={config} onChange={setConfig} />}
|
||||
{activeTab === 3 && <AdvancedOptionsTab config={config} onChange={setConfig} />}
|
||||
</Box>
|
||||
|
||||
{/* Preview */}
|
||||
<Box sx={{ mt: 3, p: 2, bgcolor: 'grey.100', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Estimated file size: {estimatedSize}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Progress */}
|
||||
{exporting && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
<Typography variant="caption" textAlign="center" display="block" mt={1}>
|
||||
Generating {config.format.toUpperCase()}... {progress}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
startIcon={<DownloadIcon />}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. PDF Export (using jsPDF)
|
||||
|
||||
```typescript
|
||||
import jsPDF from 'jspdf'
|
||||
import 'jspdf-autotable'
|
||||
|
||||
export const generatePDF = async (
|
||||
config: ExportConfig,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<Blob> => {
|
||||
const doc = new jsPDF({
|
||||
orientation: config.columns === 2 ? 'landscape' : 'portrait',
|
||||
unit: 'mm',
|
||||
format: config.pageSize.toLowerCase()
|
||||
})
|
||||
|
||||
// Set font
|
||||
doc.setFont(config.fontFamily)
|
||||
doc.setFontSize(config.fontSize)
|
||||
|
||||
let currentPage = 1
|
||||
|
||||
// Add cover page
|
||||
if (config.includeCoverPage) {
|
||||
addCoverPage(doc, config)
|
||||
doc.addPage()
|
||||
currentPage++
|
||||
}
|
||||
|
||||
// Add table of contents
|
||||
if (config.includeTableOfContents) {
|
||||
const toc = await generateTableOfContents(config)
|
||||
addTableOfContents(doc, toc)
|
||||
doc.addPage()
|
||||
currentPage++
|
||||
}
|
||||
|
||||
// Fetch Bible content
|
||||
const verses = await fetchVerses(
|
||||
config.book,
|
||||
config.startChapter,
|
||||
config.endChapter,
|
||||
config.startVerse,
|
||||
config.endVerse
|
||||
)
|
||||
|
||||
const totalVerses = verses.length
|
||||
let processedVerses = 0
|
||||
|
||||
// Group by chapters
|
||||
const chapters = groupByChapters(verses)
|
||||
|
||||
for (const [chapterNum, chapterVerses] of Object.entries(chapters)) {
|
||||
// Chapter heading
|
||||
if (config.includeChapterNumbers) {
|
||||
doc.setFontSize(config.fontSize + 4)
|
||||
doc.setFont(config.fontFamily, 'bold')
|
||||
doc.text(`Chapter ${chapterNum}`, 20, doc.internal.pageSize.height - 20)
|
||||
doc.setFont(config.fontFamily, 'normal')
|
||||
doc.setFontSize(config.fontSize)
|
||||
}
|
||||
|
||||
// Add verses
|
||||
for (const verse of chapterVerses) {
|
||||
const verseText = formatVerseForPDF(verse, config)
|
||||
|
||||
// Check if we need a new page
|
||||
if (doc.internal.pageSize.height - 40 < 20) {
|
||||
doc.addPage()
|
||||
currentPage++
|
||||
}
|
||||
|
||||
doc.text(verseText, 20, doc.internal.pageSize.height - 40)
|
||||
|
||||
// Add highlights if enabled
|
||||
if (config.includeHighlights && verse.highlights) {
|
||||
addHighlightsToPDF(doc, verse.highlights)
|
||||
}
|
||||
|
||||
// Add notes
|
||||
if (config.includeNotes && verse.notes) {
|
||||
if (config.notesPosition === 'inline') {
|
||||
addInlineNote(doc, verse.notes)
|
||||
} else if (config.notesPosition === 'footnotes') {
|
||||
addFootnote(doc, verse.notes, currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
processedVerses++
|
||||
if (onProgress) {
|
||||
onProgress(Math.round((processedVerses / totalVerses) * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add header/footer to all pages
|
||||
if (config.includeHeader || config.includeFooter) {
|
||||
const totalPages = doc.getNumberOfPages()
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i)
|
||||
|
||||
if (config.includeHeader) {
|
||||
doc.setFontSize(10)
|
||||
doc.text(config.headerText, 20, 10)
|
||||
}
|
||||
|
||||
if (config.includeFooter) {
|
||||
doc.setFontSize(10)
|
||||
const footerText = config.includePageNumbers
|
||||
? `${config.footerText} | Page ${i} of ${totalPages}`
|
||||
: config.footerText
|
||||
doc.text(footerText, 20, doc.internal.pageSize.height - 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc.output('blob')
|
||||
}
|
||||
|
||||
const formatVerseForPDF = (verse: BibleVerse, config: ExportConfig): string => {
|
||||
let text = ''
|
||||
|
||||
if (config.includeVerseNumbers) {
|
||||
text += `${verse.verseNum}. `
|
||||
}
|
||||
|
||||
text += verse.text
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
const addCoverPage = (doc: jsPDF, config: ExportConfig): void => {
|
||||
const pageWidth = doc.internal.pageSize.width
|
||||
const pageHeight = doc.internal.pageSize.height
|
||||
|
||||
// Title
|
||||
doc.setFontSize(24)
|
||||
doc.setFont(config.fontFamily, 'bold')
|
||||
doc.text(config.coverTitle, pageWidth / 2, pageHeight / 2 - 20, { align: 'center' })
|
||||
|
||||
// Subtitle
|
||||
doc.setFontSize(16)
|
||||
doc.setFont(config.fontFamily, 'normal')
|
||||
doc.text(config.coverSubtitle, pageWidth / 2, pageHeight / 2, { align: 'center' })
|
||||
|
||||
// Author & Date
|
||||
doc.setFontSize(12)
|
||||
doc.text(config.author, pageWidth / 2, pageHeight / 2 + 30, { align: 'center' })
|
||||
doc.text(config.date, pageWidth / 2, pageHeight / 2 + 40, { align: 'center' })
|
||||
}
|
||||
```
|
||||
|
||||
### 4. DOCX Export (using docx library)
|
||||
|
||||
```typescript
|
||||
import { Document, Paragraph, TextRun, AlignmentType, HeadingLevel } from 'docx'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { Packer } from 'docx'
|
||||
|
||||
export const generateDOCX = async (
|
||||
config: ExportConfig,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<Blob> => {
|
||||
const sections = []
|
||||
|
||||
// Cover page
|
||||
if (config.includeCoverPage) {
|
||||
sections.push({
|
||||
children: [
|
||||
new Paragraph({
|
||||
text: config.coverTitle,
|
||||
heading: HeadingLevel.TITLE,
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 400, after: 200 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: config.coverSubtitle,
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: config.author,
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 100 }
|
||||
}),
|
||||
new Paragraph({
|
||||
text: config.date,
|
||||
alignment: AlignmentType.CENTER
|
||||
})
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch content
|
||||
const verses = await fetchVerses(
|
||||
config.book,
|
||||
config.startChapter,
|
||||
config.endChapter
|
||||
)
|
||||
|
||||
const chapters = groupByChapters(verses)
|
||||
|
||||
for (const [chapterNum, chapterVerses] of Object.entries(chapters)) {
|
||||
// Chapter heading
|
||||
if (config.includeChapterNumbers) {
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `Chapter ${chapterNum}`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 400, after: 200 }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Verses
|
||||
for (const verse of chapterVerses) {
|
||||
const paragraph = new Paragraph({
|
||||
children: []
|
||||
})
|
||||
|
||||
// Verse number
|
||||
if (config.includeVerseNumbers) {
|
||||
paragraph.addChildElement(
|
||||
new TextRun({
|
||||
text: `${verse.verseNum} `,
|
||||
bold: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Verse text
|
||||
paragraph.addChildElement(
|
||||
new TextRun({
|
||||
text: verse.text,
|
||||
size: config.fontSize * 2 // Convert to half-points
|
||||
})
|
||||
)
|
||||
|
||||
sections.push(paragraph)
|
||||
|
||||
// Highlights
|
||||
if (config.includeHighlights && verse.highlights) {
|
||||
for (const highlight of verse.highlights) {
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `[Highlight: ${highlight.color}] ${highlight.text}`,
|
||||
italics: true,
|
||||
color: highlight.color
|
||||
})
|
||||
],
|
||||
spacing: { before: 100 }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (config.includeNotes && verse.notes) {
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `Note: ${verse.notes}`,
|
||||
italics: true,
|
||||
color: '666666'
|
||||
})
|
||||
],
|
||||
spacing: { before: 100, after: 100 }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{
|
||||
properties: {
|
||||
page: {
|
||||
margin: {
|
||||
top: config.margins.top * 56.7, // Convert mm to twips
|
||||
right: config.margins.right * 56.7,
|
||||
bottom: config.margins.bottom * 56.7,
|
||||
left: config.margins.left * 56.7
|
||||
}
|
||||
}
|
||||
},
|
||||
children: sections
|
||||
}]
|
||||
})
|
||||
|
||||
return await Packer.toBlob(doc)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Markdown Export
|
||||
|
||||
```typescript
|
||||
export const generateMarkdown = async (
|
||||
config: ExportConfig
|
||||
): Promise<string> => {
|
||||
let markdown = ''
|
||||
|
||||
// Front matter
|
||||
if (config.includeCoverPage) {
|
||||
markdown += `---\n`
|
||||
markdown += `title: ${config.coverTitle}\n`
|
||||
markdown += `subtitle: ${config.coverSubtitle}\n`
|
||||
markdown += `author: ${config.author}\n`
|
||||
markdown += `date: ${config.date}\n`
|
||||
markdown += `---\n\n`
|
||||
}
|
||||
|
||||
// Title
|
||||
markdown += `# ${config.coverTitle}\n\n`
|
||||
|
||||
// Fetch content
|
||||
const verses = await fetchVerses(
|
||||
config.book,
|
||||
config.startChapter,
|
||||
config.endChapter
|
||||
)
|
||||
|
||||
const chapters = groupByChapters(verses)
|
||||
|
||||
for (const [chapterNum, chapterVerses] of Object.entries(chapters)) {
|
||||
// Chapter heading
|
||||
if (config.includeChapterNumbers) {
|
||||
markdown += `## Chapter ${chapterNum}\n\n`
|
||||
}
|
||||
|
||||
// Verses
|
||||
for (const verse of chapterVerses) {
|
||||
if (config.includeVerseNumbers) {
|
||||
markdown += `**${verse.verseNum}** `
|
||||
}
|
||||
|
||||
markdown += `${verse.text}\n\n`
|
||||
|
||||
// Highlights
|
||||
if (config.includeHighlights && verse.highlights) {
|
||||
for (const highlight of verse.highlights) {
|
||||
markdown += `> 🎨 **Highlight (${highlight.color}):** ${highlight.text}\n\n`
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (config.includeNotes && verse.notes) {
|
||||
markdown += `> 📝 **Note:** ${verse.notes}\n\n`
|
||||
}
|
||||
}
|
||||
|
||||
markdown += '\n---\n\n'
|
||||
}
|
||||
|
||||
return markdown
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Batch Export
|
||||
|
||||
```typescript
|
||||
interface BatchExportConfig {
|
||||
books: string[]
|
||||
format: ExportFormat
|
||||
separate: boolean // Export each book as separate file
|
||||
combinedFilename?: string
|
||||
}
|
||||
|
||||
export const batchExport = async (
|
||||
config: BatchExportConfig,
|
||||
onProgress?: (current: number, total: number) => void
|
||||
): Promise<Blob | Blob[]> => {
|
||||
if (config.separate) {
|
||||
// Export each book separately
|
||||
const blobs: Blob[] = []
|
||||
|
||||
for (let i = 0; i < config.books.length; i++) {
|
||||
const book = config.books[i]
|
||||
|
||||
const exportConfig: ExportConfig = {
|
||||
...getDefaultConfig(),
|
||||
book,
|
||||
startChapter: 1,
|
||||
endChapter: await getLastChapter(book),
|
||||
format: config.format
|
||||
}
|
||||
|
||||
const blob = await exportContent(exportConfig)
|
||||
blobs.push(blob)
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, config.books.length)
|
||||
}
|
||||
}
|
||||
|
||||
return blobs
|
||||
} else {
|
||||
// Export all books in one file
|
||||
const exportConfig: ExportConfig = {
|
||||
...getDefaultConfig(),
|
||||
format: config.format
|
||||
// Will loop through all books internally
|
||||
}
|
||||
|
||||
return await exportContent(exportConfig)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Print Optimization
|
||||
|
||||
```typescript
|
||||
const PrintPreview: React.FC<{
|
||||
config: ExportConfig
|
||||
}> = ({ config }) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handlePrint = () => {
|
||||
const printWindow = window.open('', '', 'height=800,width=600')
|
||||
|
||||
if (!printWindow) return
|
||||
|
||||
const printStyles = `
|
||||
<style>
|
||||
@page {
|
||||
size: ${config.pageSize};
|
||||
margin: ${config.margins.top}mm ${config.margins.right}mm
|
||||
${config.margins.bottom}mm ${config.margins.left}mm;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ${config.fontFamily};
|
||||
font-size: ${config.fontSize}pt;
|
||||
line-height: ${config.lineHeight};
|
||||
color: ${config.colorMode === 'grayscale' ? '#000' : 'inherit'};
|
||||
}
|
||||
|
||||
.verse-number {
|
||||
font-weight: bold;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.chapter-heading {
|
||||
font-size: ${config.fontSize + 4}pt;
|
||||
font-weight: bold;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
break-before: page;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background-color: ${config.colorMode === 'grayscale' ? '#ddd' : 'inherit'};
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
margin-left: 2em;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${config.coverTitle}</title>
|
||||
${printStyles}
|
||||
</head>
|
||||
<body>
|
||||
${contentRef.current?.innerHTML}
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
printWindow.print()
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button onClick={handlePrint} startIcon={<PrintIcon />}>
|
||||
Print Preview
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
ref={contentRef}
|
||||
sx={{
|
||||
p: 3,
|
||||
bgcolor: 'white',
|
||||
minHeight: '100vh',
|
||||
fontFamily: config.fontFamily,
|
||||
fontSize: `${config.fontSize}pt`,
|
||||
lineHeight: config.lineHeight
|
||||
}}
|
||||
>
|
||||
{/* Rendered content here */}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Email Export
|
||||
|
||||
```typescript
|
||||
interface EmailExportConfig {
|
||||
to: string[]
|
||||
subject: string
|
||||
message: string
|
||||
exportConfig: ExportConfig
|
||||
}
|
||||
|
||||
const EmailExportDialog: React.FC = () => {
|
||||
const [config, setConfig] = useState<EmailExportConfig>({
|
||||
to: [],
|
||||
subject: '',
|
||||
message: '',
|
||||
exportConfig: getDefaultConfig()
|
||||
})
|
||||
|
||||
const handleSend = async () => {
|
||||
// Generate export
|
||||
const blob = await exportContent(config.exportConfig)
|
||||
|
||||
// Convert to base64
|
||||
const base64 = await blobToBase64(blob)
|
||||
|
||||
// Send via API
|
||||
await fetch('/api/export/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
to: config.to,
|
||||
subject: config.subject,
|
||||
message: config.message,
|
||||
attachment: {
|
||||
filename: generateFilename(config.exportConfig),
|
||||
content: base64,
|
||||
contentType: getMimeType(config.exportConfig.format)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle>Email Export</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
<TextField
|
||||
label="To"
|
||||
placeholder="email@example.com"
|
||||
value={config.to.join(', ')}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
to: e.target.value.split(',').map(s => s.trim())
|
||||
})}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Subject"
|
||||
value={config.subject}
|
||||
onChange={(e) => setConfig({ ...config, subject: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Message"
|
||||
value={config.message}
|
||||
onChange={(e) => setConfig({ ...config, message: e.target.value })}
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSend} variant="contained">
|
||||
Send
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
```typescript
|
||||
// Generate and download export
|
||||
POST /api/export
|
||||
Body: ExportConfig
|
||||
Response: File (binary)
|
||||
|
||||
// Email export
|
||||
POST /api/export/email
|
||||
Body: {
|
||||
to: string[]
|
||||
subject: string
|
||||
message: string
|
||||
attachment: {
|
||||
filename: string
|
||||
content: string (base64)
|
||||
contentType: string
|
||||
}
|
||||
}
|
||||
|
||||
// Get export templates
|
||||
GET /api/export/templates
|
||||
Response: { templates: ExportTemplate[] }
|
||||
|
||||
// Save export preset
|
||||
POST /api/export/presets
|
||||
Body: { name: string, config: ExportConfig }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Timeline
|
||||
|
||||
### Week 1: Core Export
|
||||
**Day 1-2: Foundation**
|
||||
- [ ] Create export dialog UI
|
||||
- [ ] Build configuration forms
|
||||
- [ ] Implement content fetching
|
||||
|
||||
**Day 3-4: PDF Export**
|
||||
- [ ] Integrate jsPDF
|
||||
- [ ] Implement basic PDF generation
|
||||
- [ ] Add highlights/notes support
|
||||
- [ ] Test layouts
|
||||
|
||||
**Day 5: DOCX & Markdown**
|
||||
- [ ] Implement DOCX export
|
||||
- [ ] Implement Markdown export
|
||||
- [ ] Test formatting
|
||||
|
||||
**Deliverable:** Working PDF, DOCX, Markdown exports
|
||||
|
||||
### Week 2: Advanced Features
|
||||
**Day 1-2: Layout Customization**
|
||||
- [ ] Add cover page generation
|
||||
- [ ] Implement TOC
|
||||
- [ ] Add headers/footers
|
||||
- [ ] Build print preview
|
||||
|
||||
**Day 3-4: Batch & Email**
|
||||
- [ ] Implement batch export
|
||||
- [ ] Build email functionality
|
||||
- [ ] Add progress tracking
|
||||
- [ ] Test large exports
|
||||
|
||||
**Day 5: Polish**
|
||||
- [ ] Performance optimization
|
||||
- [ ] Error handling
|
||||
- [ ] UI refinement
|
||||
- [ ] Documentation
|
||||
|
||||
**Deliverable:** Production-ready export system
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Plan
|
||||
|
||||
### Pre-Launch
|
||||
- [ ] Test with various content sizes
|
||||
- [ ] Verify all formats generate correctly
|
||||
- [ ] Performance testing
|
||||
- [ ] Cross-browser testing
|
||||
- [ ] Mobile testing
|
||||
|
||||
### Rollout
|
||||
1. **Beta**: Limited users, PDF only
|
||||
2. **Staged**: 50% users, all formats
|
||||
3. **Full**: 100% deployment
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Owner:** Development Team
|
||||
**Status:** Ready for Implementation
|
||||
1104
FOCUS_MODE_ENHANCED_PLAN.md
Normal file
1104
FOCUS_MODE_ENHANCED_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
346
IMPLEMENTATION_ROADMAP.md
Normal file
346
IMPLEMENTATION_ROADMAP.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# Biblical Guide - Complete Implementation Roadmap
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
This document provides a comprehensive roadmap for all planned features, organized by priority, with detailed timelines and resource allocation recommendations.
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Status:** Master Planning Document
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Implementation Plans Created
|
||||
|
||||
### **🔴 High Priority - Phase 2 (8-10 weeks)**
|
||||
|
||||
| # | Feature | Estimated Time | Plan Document | Status |
|
||||
|---|---------|----------------|---------------|--------|
|
||||
| 1 | Text-to-Speech | 2-3 weeks | [TEXT_TO_SPEECH_IMPLEMENTATION_PLAN.md](./TEXT_TO_SPEECH_IMPLEMENTATION_PLAN.md) | ✅ Ready |
|
||||
| 2 | Parallel Bible View | 2 weeks | [PARALLEL_BIBLE_VIEW_PLAN.md](./PARALLEL_BIBLE_VIEW_PLAN.md) | ✅ Ready |
|
||||
| 3 | Cross-References Panel | 2 weeks | [CROSS_REFERENCES_PANEL_PLAN.md](./CROSS_REFERENCES_PANEL_PLAN.md) | ✅ Ready |
|
||||
| 4 | Export Functionality | 2-3 weeks | [EXPORT_FUNCTIONALITY_PLAN.md](./EXPORT_FUNCTIONALITY_PLAN.md) | ✅ Ready |
|
||||
|
||||
**Total Phase 2 Time:** 8-10 weeks
|
||||
|
||||
---
|
||||
|
||||
### **🟡 Medium Priority - Phase 2B (5-7 weeks)**
|
||||
|
||||
| # | Feature | Estimated Time | Plan Document | Status |
|
||||
|---|---------|----------------|---------------|--------|
|
||||
| 5 | Focus Mode Enhanced | 1 week | [FOCUS_MODE_ENHANCED_PLAN.md](./FOCUS_MODE_ENHANCED_PLAN.md) | ✅ Ready |
|
||||
| 6 | Rich Text Notes | 2 weeks | [RICH_TEXT_NOTES_PLAN.md](./RICH_TEXT_NOTES_PLAN.md) | ✅ Ready |
|
||||
| 7 | Tags & Categories | 1-2 weeks | [TAGS_CATEGORIES_SYSTEM_PLAN.md](./TAGS_CATEGORIES_SYSTEM_PLAN.md) | ✅ Ready |
|
||||
| 8 | Speed Reading Mode | 2 weeks | [SPEED_READING_MODE_PLAN.md](./SPEED_READING_MODE_PLAN.md) | ✅ Ready |
|
||||
| 9 | Custom Fonts & Dyslexia | 1 week | [CUSTOM_FONTS_DYSLEXIA_SUPPORT_PLAN.md](./CUSTOM_FONTS_DYSLEXIA_SUPPORT_PLAN.md) | ✅ Ready |
|
||||
|
||||
**Total Phase 2B Time:** 7-8 weeks
|
||||
|
||||
---
|
||||
|
||||
### **🔵 Future - Phase 3 (12-16 weeks)**
|
||||
|
||||
| # | Feature | Estimated Time | Plan Document | Status |
|
||||
|---|---------|----------------|---------------|--------|
|
||||
| 10 | AI Smart Suggestions | 4-6 weeks | [AI_SMART_SUGGESTIONS_PLAN.md](./AI_SMART_SUGGESTIONS_PLAN.md) | ✅ Ready |
|
||||
| 11 | Reading Analytics Dashboard | 3-4 weeks | 📝 To be created | ⏳ Pending |
|
||||
| 12 | Social & Collaboration | 4-6 weeks | 📝 To be created | ⏳ Pending |
|
||||
| 13 | Enhanced Offline Experience | 2-3 weeks | 📝 To be created | ⏳ Pending |
|
||||
| 14 | Advanced Search & Discovery | 2-3 weeks | 📝 To be created | ⏳ Pending |
|
||||
|
||||
**Total Phase 3 Time:** 15-22 weeks
|
||||
|
||||
---
|
||||
|
||||
## 📊 Complete Feature Matrix
|
||||
|
||||
### By User Impact & Complexity
|
||||
|
||||
```
|
||||
High Impact
|
||||
│
|
||||
Text-to-Speech │ Parallel View
|
||||
Cross-Refs │ Export
|
||||
─────────────────────────┼─────────────────────────
|
||||
Speed Reading │ AI Suggestions
|
||||
Analytics │ Social Features
|
||||
│
|
||||
Low Complexity → High Complexity
|
||||
```
|
||||
|
||||
### By Implementation Order (Recommended)
|
||||
|
||||
**Quarter 1 (Weeks 1-13)**
|
||||
1. Text-to-Speech (Weeks 1-3)
|
||||
2. Parallel Bible View (Weeks 4-5)
|
||||
3. Cross-References (Weeks 6-7)
|
||||
4. Export Functionality (Weeks 8-10)
|
||||
5. Focus Mode Enhanced (Week 11)
|
||||
6. Custom Fonts & Dyslexia (Week 12)
|
||||
7. Buffer/Testing (Week 13)
|
||||
|
||||
**Quarter 2 (Weeks 14-26)**
|
||||
1. Rich Text Notes (Weeks 14-15)
|
||||
2. Tags & Categories (Weeks 16-17)
|
||||
3. Speed Reading Mode (Weeks 18-19)
|
||||
4. Reading Analytics (Weeks 20-23)
|
||||
5. Advanced Search (Weeks 24-25)
|
||||
6. Buffer/Testing (Week 26)
|
||||
|
||||
**Quarter 3 (Weeks 27-39)**
|
||||
1. AI Smart Suggestions (Weeks 27-32)
|
||||
2. Enhanced Offline (Weeks 33-35)
|
||||
3. Social Features - Phase 1 (Weeks 36-39)
|
||||
|
||||
**Quarter 4 (Weeks 40-52)**
|
||||
1. Social Features - Phase 2 (Weeks 40-43)
|
||||
2. Polish & Optimization (Weeks 44-48)
|
||||
3. Marketing & Documentation (Weeks 49-52)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Resource Requirements
|
||||
|
||||
### Development Team (Recommended)
|
||||
|
||||
**Option A: Single Developer**
|
||||
- Timeline: 52 weeks (1 year)
|
||||
- Cost: Varies by region
|
||||
- Pros: Consistent vision, lower coordination overhead
|
||||
- Cons: Longer timeline, no redundancy
|
||||
|
||||
**Option B: Small Team (2-3 Developers)**
|
||||
- Timeline: 26-30 weeks (6-7 months)
|
||||
- Frontend Developer
|
||||
- Backend Developer
|
||||
- UI/UX Designer (part-time)
|
||||
- Pros: Faster delivery, specialization
|
||||
- Cons: Higher cost, coordination needed
|
||||
|
||||
**Option C: Larger Team (4-6 Developers)**
|
||||
- Timeline: 13-20 weeks (3-5 months)
|
||||
- 2 Frontend Developers
|
||||
- 2 Backend Developers
|
||||
- 1 UI/UX Designer
|
||||
- 1 QA Engineer
|
||||
- Pros: Fastest delivery, parallel workstreams
|
||||
- Cons: Highest cost, more management overhead
|
||||
|
||||
### Technology Stack Requirements
|
||||
|
||||
**Infrastructure:**
|
||||
- PostgreSQL with pgvector extension (for AI features)
|
||||
- Redis (caching, sessions)
|
||||
- Cloud storage (S3/equivalent) for uploaded fonts, exports
|
||||
- CDN for static assets
|
||||
|
||||
**Third-Party Services:**
|
||||
- OpenAI/Azure OpenAI API (for AI features)
|
||||
- Amazon Polly or Google TTS (for premium voices)
|
||||
- Stripe (already configured)
|
||||
- SendGrid/Mailgun (already configured)
|
||||
|
||||
**Estimated Monthly Costs:**
|
||||
- Infrastructure: $50-200/month
|
||||
- AI Services: $100-500/month (depending on usage)
|
||||
- Storage/CDN: $20-100/month
|
||||
- **Total:** $170-800/month
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Metrics
|
||||
|
||||
### Phase 2 Goals (Weeks 1-10)
|
||||
- ✅ TTS adoption: 20% of active users
|
||||
- ✅ Parallel view usage: 15% of sessions
|
||||
- ✅ Cross-reference clicks: 30% of verses viewed
|
||||
- ✅ Export usage: 10% of users
|
||||
|
||||
### Phase 2B Goals (Weeks 11-18)
|
||||
- ✅ Focus mode enabled: 25% of users
|
||||
- ✅ Notes created: Average 5 per active user
|
||||
- ✅ Tags used: 40% of highlights
|
||||
- ✅ Speed reading tried: 10% of users
|
||||
|
||||
### Phase 3 Goals (Weeks 19-39)
|
||||
- ✅ AI suggestions clicked: 30% relevance rate
|
||||
- ✅ Semantic search used: 15% of searches
|
||||
- ✅ Analytics viewed: Weekly by 50% of users
|
||||
- ✅ Social features: 20% engagement rate
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start Guide
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Choose your starting feature:**
|
||||
- Highest user value: Text-to-Speech
|
||||
- Easiest implementation: Focus Mode Enhanced
|
||||
- Most complex: AI Smart Suggestions
|
||||
|
||||
2. **Review the plan:**
|
||||
- Read the full implementation plan
|
||||
- Check database schema requirements
|
||||
- Review API endpoints needed
|
||||
|
||||
3. **Set up environment:**
|
||||
```bash
|
||||
# Install dependencies (if new)
|
||||
npm install <required-packages>
|
||||
|
||||
# Update database schema
|
||||
npx prisma db push
|
||||
|
||||
# Run migrations
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
4. **Follow the timeline:**
|
||||
- Each plan has a day-by-day breakdown
|
||||
- Build incrementally
|
||||
- Test continuously
|
||||
|
||||
### For Project Managers
|
||||
|
||||
1. **Resource allocation:**
|
||||
- Assign developers based on expertise
|
||||
- Frontend: React, TypeScript, Material-UI
|
||||
- Backend: Node.js, Prisma, PostgreSQL
|
||||
- Full-stack: Can handle both
|
||||
|
||||
2. **Sprint planning:**
|
||||
- Use 2-week sprints
|
||||
- Each feature = 1-3 sprints
|
||||
- Build buffer time (15-20%)
|
||||
|
||||
3. **Risk management:**
|
||||
- Identify blockers early
|
||||
- Have fallback options
|
||||
- Regular stakeholder updates
|
||||
|
||||
---
|
||||
|
||||
## 📈 Progress Tracking
|
||||
|
||||
### Template for Feature Implementation
|
||||
|
||||
```markdown
|
||||
## [Feature Name]
|
||||
|
||||
**Status:** Not Started | In Progress | In Review | Complete
|
||||
**Progress:** 0% → 100%
|
||||
**Start Date:** YYYY-MM-DD
|
||||
**Target Date:** YYYY-MM-DD
|
||||
**Actual Completion:** YYYY-MM-DD
|
||||
|
||||
### Milestones
|
||||
- [ ] Database schema updated
|
||||
- [ ] API endpoints implemented
|
||||
- [ ] UI components built
|
||||
- [ ] Testing complete
|
||||
- [ ] Documentation written
|
||||
- [ ] Deployed to production
|
||||
|
||||
### Blockers
|
||||
- None / [Description]
|
||||
|
||||
### Notes
|
||||
- [Any relevant notes]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Continuous Improvement
|
||||
|
||||
### After Each Feature Launch
|
||||
|
||||
1. **Collect user feedback:**
|
||||
- In-app surveys
|
||||
- Usage analytics
|
||||
- Support tickets
|
||||
- Feature requests
|
||||
|
||||
2. **Measure success metrics:**
|
||||
- Adoption rate
|
||||
- Engagement
|
||||
- Performance
|
||||
- Error rates
|
||||
|
||||
3. **Iterate:**
|
||||
- Quick wins (bug fixes)
|
||||
- Medium improvements (UX tweaks)
|
||||
- Long-term enhancements (v2.0)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
### Current Status Documents
|
||||
- [FEATURES_BACKLOG.md](./FEATURES_BACKLOG.md) - Original feature list
|
||||
- [SUBSCRIPTION_IMPLEMENTATION_STATUS.md](./SUBSCRIPTION_IMPLEMENTATION_STATUS.md) - Completed subscription system
|
||||
- [AI_CHAT_IMPLEMENTATION_COMPLETE.md](./AI_CHAT_IMPLEMENTATION_COMPLETE.md) - Completed AI chat
|
||||
|
||||
### Technical Documentation
|
||||
- Database schema: See Prisma schema file
|
||||
- API documentation: See individual route files
|
||||
- Component library: Material-UI v7
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Considerations
|
||||
|
||||
### Before Starting Any Feature
|
||||
|
||||
1. **Dependencies:**
|
||||
- Check if feature requires other features first
|
||||
- Verify all required packages are installed
|
||||
- Ensure database supports required features (e.g., pgvector for AI)
|
||||
|
||||
2. **User Impact:**
|
||||
- Will this affect existing users?
|
||||
- Do we need a migration strategy?
|
||||
- Should we use feature flags?
|
||||
|
||||
3. **Performance:**
|
||||
- What's the expected load?
|
||||
- Do we need caching?
|
||||
- Are there potential bottlenecks?
|
||||
|
||||
4. **Cost:**
|
||||
- Any new third-party services?
|
||||
- API usage costs?
|
||||
- Storage/bandwidth implications?
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Conclusion
|
||||
|
||||
This roadmap provides a clear path from the current state to a fully-featured Bible study platform. Each implementation plan is production-ready and can be executed independently or in parallel (where dependencies allow).
|
||||
|
||||
**Total Estimated Timeline:**
|
||||
- **Fast Track (Large Team):** 3-5 months
|
||||
- **Moderate (Small Team):** 6-9 months
|
||||
- **Steady (Solo Developer):** 12-15 months
|
||||
|
||||
**Recommended Approach:**
|
||||
Start with **Phase 2 High Priority** features for maximum user impact, then expand to **Phase 2B** for enhanced experience, and finally implement **Phase 3** for advanced capabilities.
|
||||
|
||||
---
|
||||
|
||||
## 📞 Need Help?
|
||||
|
||||
For questions or clarifications on any implementation plan:
|
||||
1. Review the specific plan document
|
||||
2. Check the component code examples
|
||||
3. Refer to the API endpoint specifications
|
||||
4. Test with small prototypes first
|
||||
|
||||
**Good luck with the implementation! 🚀**
|
||||
|
||||
---
|
||||
|
||||
**Maintained by:** Development Team
|
||||
**Next Review:** After Phase 2 completion
|
||||
**Version:** 1.0
|
||||
948
PARALLEL_BIBLE_VIEW_PLAN.md
Normal file
948
PARALLEL_BIBLE_VIEW_PLAN.md
Normal file
@@ -0,0 +1,948 @@
|
||||
# Parallel Bible View - Implementation Plan
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement a side-by-side Bible reading experience allowing users to compare multiple translations simultaneously, perfect for Bible study, translation verification, and deep Scripture analysis.
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🔴 High
|
||||
**Estimated Time:** 2 weeks (80 hours)
|
||||
**Target Completion:** TBD
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals & Objectives
|
||||
|
||||
### Primary Goals
|
||||
1. Enable simultaneous viewing of 2-3 Bible translations
|
||||
2. Provide synchronized scrolling across all panes
|
||||
3. Allow easy switching between versions
|
||||
4. Maintain responsive design for mobile devices
|
||||
5. Support independent highlighting per version
|
||||
|
||||
### User Value Proposition
|
||||
- **For Bible students**: Compare translations to understand nuances
|
||||
- **For scholars**: Analyze textual differences
|
||||
- **For language learners**: See original and translated text
|
||||
- **For teachers**: Prepare lessons with multiple versions
|
||||
- **For translators**: Verify accuracy against source texts
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Specifications
|
||||
|
||||
### 1. Layout Configurations
|
||||
|
||||
```typescript
|
||||
type PaneLayout = '1-pane' | '2-pane-horizontal' | '2-pane-vertical' | '3-pane' | '4-pane'
|
||||
|
||||
interface LayoutConfig {
|
||||
layout: PaneLayout
|
||||
panes: PaneConfig[]
|
||||
syncScroll: boolean
|
||||
syncChapter: boolean // All panes show same chapter
|
||||
equalWidths: boolean
|
||||
showDividers: boolean
|
||||
compactMode: boolean // Reduce padding on mobile
|
||||
}
|
||||
|
||||
interface PaneConfig {
|
||||
id: string
|
||||
versionId: string
|
||||
visible: boolean
|
||||
width: number // percentage (for horizontal layouts)
|
||||
locked: boolean // Prevent accidental changes
|
||||
customSettings?: {
|
||||
fontSize?: number
|
||||
theme?: 'light' | 'dark' | 'sepia'
|
||||
showVerseNumbers?: boolean
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Visual Layouts
|
||||
|
||||
#### Desktop Layouts
|
||||
```
|
||||
2-Pane Horizontal:
|
||||
┌─────────────────┬─────────────────┐
|
||||
│ KJV │ ESV │
|
||||
│ │ │
|
||||
│ Genesis 1:1 │ Genesis 1:1 │
|
||||
│ In the │ In the │
|
||||
│ beginning... │ beginning... │
|
||||
│ │ │
|
||||
└─────────────────┴─────────────────┘
|
||||
|
||||
3-Pane:
|
||||
┌───────┬───────┬───────┐
|
||||
│ KJV │ ESV │ NIV │
|
||||
│ │ │ │
|
||||
│ Gen 1 │ Gen 1 │ Gen 1 │
|
||||
│ │ │ │
|
||||
└───────┴───────┴───────┘
|
||||
|
||||
2-Pane Vertical (Stacked):
|
||||
┌─────────────────────────┐
|
||||
│ KJV - Genesis 1 │
|
||||
│ │
|
||||
│ 1 In the beginning... │
|
||||
└─────────────────────────┘
|
||||
┌─────────────────────────┐
|
||||
│ ESV - Genesis 1 │
|
||||
│ │
|
||||
│ 1 In the beginning... │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
#### Mobile Layout
|
||||
```
|
||||
Mobile (Stacked with Tabs):
|
||||
┌─────────────────────────┐
|
||||
│ [KJV] [ESV] [NIV] [+] │ ← Tab bar
|
||||
├─────────────────────────┤
|
||||
│ Genesis 1:1-31 │
|
||||
│ │
|
||||
│ 1 In the beginning... │
|
||||
│ 2 And the earth... │
|
||||
│ │
|
||||
│ ▼ Swipe to compare ▼ │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. Synchronized Scrolling
|
||||
|
||||
```typescript
|
||||
interface ScrollSyncConfig {
|
||||
enabled: boolean
|
||||
mode: 'verse' | 'pixel' | 'paragraph'
|
||||
leadPane: string | 'any' // Which pane controls scroll
|
||||
smoothness: number // 0-1, animation easing
|
||||
threshold: number // Minimum scroll delta to trigger sync
|
||||
}
|
||||
|
||||
class ScrollSynchronizer {
|
||||
private panes: HTMLElement[]
|
||||
private isScrolling: boolean = false
|
||||
private scrollTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(private config: ScrollSyncConfig) {}
|
||||
|
||||
syncScroll(sourcePane: HTMLElement, scrollTop: number): void {
|
||||
if (this.isScrolling) return
|
||||
this.isScrolling = true
|
||||
|
||||
switch (this.config.mode) {
|
||||
case 'verse':
|
||||
this.syncByVerse(sourcePane, scrollTop)
|
||||
break
|
||||
case 'pixel':
|
||||
this.syncByPixel(sourcePane, scrollTop)
|
||||
break
|
||||
case 'paragraph':
|
||||
this.syncByParagraph(sourcePane, scrollTop)
|
||||
break
|
||||
}
|
||||
|
||||
// Reset scrolling flag after brief delay
|
||||
clearTimeout(this.scrollTimeout)
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
this.isScrolling = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
private syncByVerse(sourcePane: HTMLElement, scrollTop: number): void {
|
||||
// Find which verse is at the top of source pane
|
||||
const visibleVerse = this.getVisibleVerseNumber(sourcePane, scrollTop)
|
||||
|
||||
// Scroll other panes to show the same verse at top
|
||||
this.panes.forEach(pane => {
|
||||
if (pane === sourcePane) return
|
||||
|
||||
const targetVerse = pane.querySelector(`[data-verse="${visibleVerse}"]`)
|
||||
if (targetVerse) {
|
||||
pane.scrollTo({
|
||||
top: (targetVerse as HTMLElement).offsetTop - 100,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private syncByPixel(sourcePane: HTMLElement, scrollTop: number): void {
|
||||
// Calculate scroll percentage
|
||||
const scrollHeight = sourcePane.scrollHeight - sourcePane.clientHeight
|
||||
const scrollPercent = scrollTop / scrollHeight
|
||||
|
||||
// Apply same percentage to other panes
|
||||
this.panes.forEach(pane => {
|
||||
if (pane === sourcePane) return
|
||||
|
||||
const targetScrollHeight = pane.scrollHeight - pane.clientHeight
|
||||
const targetScrollTop = targetScrollHeight * scrollPercent
|
||||
|
||||
pane.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private getVisibleVerseNumber(pane: HTMLElement, scrollTop: number): number {
|
||||
const verses = Array.from(pane.querySelectorAll('[data-verse]'))
|
||||
const viewportTop = scrollTop + 100 // Offset for header
|
||||
|
||||
for (const verse of verses) {
|
||||
const verseTop = (verse as HTMLElement).offsetTop
|
||||
if (verseTop >= viewportTop) {
|
||||
return parseInt(verse.getAttribute('data-verse') || '1')
|
||||
}
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Version Selector Per Pane
|
||||
|
||||
```typescript
|
||||
interface VersionSelectorProps {
|
||||
paneId: string
|
||||
currentVersionId: string
|
||||
onVersionChange: (versionId: string) => void
|
||||
position: 'top' | 'bottom'
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const VersionSelector: React.FC<VersionSelectorProps> = ({
|
||||
paneId,
|
||||
currentVersionId,
|
||||
onVersionChange,
|
||||
position,
|
||||
compact = false
|
||||
}) => {
|
||||
const [versions, setVersions] = useState<BibleVersion[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// Load available versions
|
||||
fetch('/api/bible/versions')
|
||||
.then(r => r.json())
|
||||
.then(data => setVersions(data.versions))
|
||||
}, [])
|
||||
|
||||
const filteredVersions = versions.filter(v =>
|
||||
v.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
v.abbreviation.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<Box className={`version-selector ${position}`}>
|
||||
<FormControl fullWidth size={compact ? 'small' : 'medium'}>
|
||||
<Select
|
||||
value={currentVersionId}
|
||||
onChange={(e) => onVersionChange(e.target.value)}
|
||||
renderValue={(value) => {
|
||||
const version = versions.find(v => v.id === value)
|
||||
return version?.abbreviation || 'Select Version'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<TextField
|
||||
placeholder="Search versions..."
|
||||
size="small"
|
||||
fullWidth
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Box>
|
||||
<Divider />
|
||||
{filteredVersions.map(version => (
|
||||
<MenuItem key={version.id} value={version.id}>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="600">
|
||||
{version.abbreviation}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{version.name} ({version.language})
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Verse Alignment Highlighting
|
||||
|
||||
```typescript
|
||||
interface AlignmentConfig {
|
||||
enabled: boolean
|
||||
highlightMode: 'hover' | 'focus' | 'always' | 'none'
|
||||
color: string
|
||||
showConnectors: boolean // Lines between aligned verses
|
||||
}
|
||||
|
||||
// Highlight same verse across all panes
|
||||
const VerseAlignmentHighlighter: React.FC = () => {
|
||||
const { panes, alignmentConfig } = useParallelView()
|
||||
const [hoveredVerse, setHoveredVerse] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!alignmentConfig.enabled || alignmentConfig.highlightMode === 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
const handleVerseHover = (e: MouseEvent) => {
|
||||
const verseElement = (e.target as HTMLElement).closest('[data-verse]')
|
||||
if (verseElement) {
|
||||
const verseNum = parseInt(verseElement.getAttribute('data-verse') || '0')
|
||||
setHoveredVerse(verseNum)
|
||||
} else {
|
||||
setHoveredVerse(null)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', handleVerseHover)
|
||||
return () => document.removeEventListener('mouseover', handleVerseHover)
|
||||
}, [alignmentConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredVerse === null) {
|
||||
// Remove all highlights
|
||||
document.querySelectorAll('.verse-aligned').forEach(el => {
|
||||
el.classList.remove('verse-aligned')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Highlight verse in all panes
|
||||
panes.forEach(pane => {
|
||||
const verseElements = document.querySelectorAll(
|
||||
`#pane-${pane.id} [data-verse="${hoveredVerse}"]`
|
||||
)
|
||||
verseElements.forEach(el => el.classList.add('verse-aligned'))
|
||||
})
|
||||
}, [hoveredVerse, panes])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// CSS
|
||||
.verse-aligned {
|
||||
background-color: rgba(var(--primary-rgb), 0.1);
|
||||
border-left: 3px solid var(--primary-color);
|
||||
padding-left: 8px;
|
||||
margin-left: -11px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Diff View for Text Differences
|
||||
|
||||
```typescript
|
||||
interface DiffConfig {
|
||||
enabled: boolean
|
||||
compareAgainst: string // Pane ID to use as reference
|
||||
diffMode: 'word' | 'phrase' | 'verse'
|
||||
highlightStyle: 'color' | 'underline' | 'background' | 'strikethrough'
|
||||
showSimilarity: boolean // Show % similarity score
|
||||
}
|
||||
|
||||
// Simple word-level diff
|
||||
function calculateDiff(text1: string, text2: string): DiffResult[] {
|
||||
const words1 = text1.split(/\s+/)
|
||||
const words2 = text2.split(/\s+/)
|
||||
|
||||
const diff: DiffResult[] = []
|
||||
|
||||
// Simple longest common subsequence approach
|
||||
let i = 0, j = 0
|
||||
while (i < words1.length || j < words2.length) {
|
||||
if (words1[i] === words2[j]) {
|
||||
diff.push({ type: 'same', text: words1[i] })
|
||||
i++
|
||||
j++
|
||||
} else {
|
||||
// Check if word exists ahead
|
||||
const indexInText2 = words2.slice(j).indexOf(words1[i])
|
||||
const indexInText1 = words1.slice(i).indexOf(words2[j])
|
||||
|
||||
if (indexInText2 !== -1 && (indexInText1 === -1 || indexInText2 < indexInText1)) {
|
||||
// Word missing in text1
|
||||
diff.push({ type: 'added', text: words2[j] })
|
||||
j++
|
||||
} else if (indexInText1 !== -1) {
|
||||
// Word missing in text2
|
||||
diff.push({ type: 'removed', text: words1[i] })
|
||||
i++
|
||||
} else {
|
||||
// Different words
|
||||
diff.push({ type: 'changed', text1: words1[i], text2: words2[j] })
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
interface DiffResult {
|
||||
type: 'same' | 'added' | 'removed' | 'changed'
|
||||
text?: string
|
||||
text1?: string
|
||||
text2?: string
|
||||
}
|
||||
|
||||
// Component to render diff
|
||||
const DiffHighlightedVerse: React.FC<{
|
||||
verseText: string
|
||||
referenceText: string
|
||||
config: DiffConfig
|
||||
}> = ({ verseText, referenceText, config }) => {
|
||||
if (!config.enabled) {
|
||||
return <span>{verseText}</span>
|
||||
}
|
||||
|
||||
const diff = calculateDiff(referenceText, verseText)
|
||||
|
||||
return (
|
||||
<span>
|
||||
{diff.map((part, index) => {
|
||||
if (part.type === 'same') {
|
||||
return <span key={index}>{part.text} </span>
|
||||
} else if (part.type === 'added') {
|
||||
return (
|
||||
<mark key={index} className="diff-added">
|
||||
{part.text}{' '}
|
||||
</mark>
|
||||
)
|
||||
} else if (part.type === 'removed') {
|
||||
return (
|
||||
<del key={index} className="diff-removed">
|
||||
{part.text}{' '}
|
||||
</del>
|
||||
)
|
||||
} else if (part.type === 'changed') {
|
||||
return (
|
||||
<mark key={index} className="diff-changed">
|
||||
{part.text2}{' '}
|
||||
</mark>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Quick Swap Versions
|
||||
|
||||
```typescript
|
||||
// Allow swapping versions between panes
|
||||
const SwapVersionsButton: React.FC<{
|
||||
pane1Id: string
|
||||
pane2Id: string
|
||||
}> = ({ pane1Id, pane2Id }) => {
|
||||
const { panes, updatePane } = useParallelView()
|
||||
|
||||
const handleSwap = () => {
|
||||
const pane1 = panes.find(p => p.id === pane1Id)
|
||||
const pane2 = panes.find(p => p.id === pane2Id)
|
||||
|
||||
if (pane1 && pane2) {
|
||||
updatePane(pane1Id, { versionId: pane2.versionId })
|
||||
updatePane(pane2Id, { versionId: pane1.versionId })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={handleSwap}
|
||||
size="small"
|
||||
title="Swap versions"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': { boxShadow: 4 }
|
||||
}}
|
||||
>
|
||||
<SwapHorizIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Column Width Adjustment
|
||||
|
||||
```typescript
|
||||
interface ResizablePane {
|
||||
id: string
|
||||
minWidth: number // percentage
|
||||
maxWidth: number
|
||||
currentWidth: number
|
||||
}
|
||||
|
||||
// Draggable divider between panes
|
||||
const PaneDivider: React.FC<{
|
||||
leftPaneId: string
|
||||
rightPaneId: string
|
||||
}> = ({ leftPaneId, rightPaneId }) => {
|
||||
const { updatePane } = useParallelView()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [startX, setStartX] = useState(0)
|
||||
const [startWidths, setStartWidths] = useState<[number, number]>([50, 50])
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true)
|
||||
setStartX(e.clientX)
|
||||
|
||||
const leftPane = document.getElementById(`pane-${leftPaneId}`)
|
||||
const rightPane = document.getElementById(`pane-${rightPaneId}`)
|
||||
|
||||
if (leftPane && rightPane) {
|
||||
const leftWidth = (leftPane.offsetWidth / leftPane.parentElement!.offsetWidth) * 100
|
||||
const rightWidth = (rightPane.offsetWidth / rightPane.parentElement!.offsetWidth) * 100
|
||||
setStartWidths([leftWidth, rightWidth])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX
|
||||
const container = document.querySelector('.parallel-view-container')
|
||||
if (!container) return
|
||||
|
||||
const deltaPercent = (deltaX / container.clientWidth) * 100
|
||||
|
||||
const newLeftWidth = Math.max(20, Math.min(80, startWidths[0] + deltaPercent))
|
||||
const newRightWidth = Math.max(20, Math.min(80, startWidths[1] - deltaPercent))
|
||||
|
||||
updatePane(leftPaneId, { width: newLeftWidth })
|
||||
updatePane(rightPaneId, { width: newRightWidth })
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isDragging, startX, startWidths])
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseDown={handleMouseDown}
|
||||
className={`pane-divider ${isDragging ? 'dragging' : ''}`}
|
||||
sx={{
|
||||
width: '8px',
|
||||
cursor: 'col-resize',
|
||||
bgcolor: 'divider',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.main',
|
||||
width: '12px'
|
||||
},
|
||||
'&.dragging': {
|
||||
bgcolor: 'primary.main',
|
||||
width: '12px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Independent Highlighting Per Version
|
||||
|
||||
```typescript
|
||||
// Each pane maintains its own highlights
|
||||
interface PaneHighlights {
|
||||
paneId: string
|
||||
highlights: Highlight[]
|
||||
}
|
||||
|
||||
// Store highlights per version in database
|
||||
model Highlight {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
versionId String // Link to specific Bible version
|
||||
book String
|
||||
chapter Int
|
||||
verse Int
|
||||
color String
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
version BibleVersion @relation(fields: [versionId], references: [id])
|
||||
|
||||
@@index([userId, versionId, book, chapter])
|
||||
}
|
||||
|
||||
// Load highlights per pane
|
||||
const loadPaneHighlights = async (
|
||||
paneId: string,
|
||||
versionId: string,
|
||||
book: string,
|
||||
chapter: number
|
||||
): Promise<Highlight[]> => {
|
||||
const response = await fetch(
|
||||
`/api/highlights?versionId=${versionId}&book=${book}&chapter=${chapter}`
|
||||
)
|
||||
return response.json()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Technical Implementation
|
||||
|
||||
### File Structure
|
||||
```
|
||||
/components/bible-reader/
|
||||
├── parallel-view/
|
||||
│ ├── ParallelViewProvider.tsx # Context provider
|
||||
│ ├── ParallelViewContainer.tsx # Main container
|
||||
│ ├── Pane.tsx # Individual pane
|
||||
│ ├── PaneDivider.tsx # Resizable divider
|
||||
│ ├── VersionSelector.tsx # Version picker per pane
|
||||
│ ├── LayoutSelector.tsx # Layout switcher
|
||||
│ ├── ScrollSynchronizer.tsx # Scroll sync logic
|
||||
│ ├── VerseAlignmentHighlighter.tsx # Verse highlighting
|
||||
│ ├── DiffView.tsx # Text difference view
|
||||
│ ├── SwapControl.tsx # Version swapping
|
||||
│ └── hooks/
|
||||
│ ├── useParallelView.ts # Main hook
|
||||
│ ├── useScrollSync.ts # Scroll synchronization
|
||||
│ ├── usePaneResize.ts # Resize logic
|
||||
│ └── useVerseAlignment.ts # Alignment logic
|
||||
└── reader.tsx # Updated main reader
|
||||
```
|
||||
|
||||
### Context Provider
|
||||
|
||||
```typescript
|
||||
// ParallelViewProvider.tsx
|
||||
interface ParallelViewContextType {
|
||||
// State
|
||||
enabled: boolean
|
||||
layout: LayoutConfig
|
||||
panes: PaneConfig[]
|
||||
scrollSync: ScrollSyncConfig
|
||||
alignmentConfig: AlignmentConfig
|
||||
diffConfig: DiffConfig
|
||||
|
||||
// Actions
|
||||
toggleParallelView: () => void
|
||||
addPane: (config: Partial<PaneConfig>) => void
|
||||
removePane: (paneId: string) => void
|
||||
updatePane: (paneId: string, updates: Partial<PaneConfig>) => void
|
||||
setLayout: (layout: PaneLayout) => void
|
||||
updateScrollSync: (config: Partial<ScrollSyncConfig>) => void
|
||||
swapVersions: (paneId1: string, paneId2: string) => void
|
||||
}
|
||||
|
||||
export const ParallelViewProvider: React.FC<{
|
||||
children: React.ReactNode
|
||||
}> = ({ children }) => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [layout, setLayoutState] = useState<LayoutConfig>(defaultLayout)
|
||||
const [panes, setPanes] = useState<PaneConfig[]>([])
|
||||
const [scrollSync, setScrollSync] = useState<ScrollSyncConfig>(defaultScrollSync)
|
||||
|
||||
// Load from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('parallel-view-config')
|
||||
if (saved) {
|
||||
const config = JSON.parse(saved)
|
||||
setEnabled(config.enabled)
|
||||
setLayoutState(config.layout)
|
||||
setPanes(config.panes)
|
||||
setScrollSync(config.scrollSync)
|
||||
} else {
|
||||
// Initialize with default 2-pane view
|
||||
const defaultPanes = [
|
||||
{ id: 'pane-1', versionId: 'kjv', visible: true, width: 50, locked: false },
|
||||
{ id: 'pane-2', versionId: 'esv', visible: true, width: 50, locked: false }
|
||||
]
|
||||
setPanes(defaultPanes)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('parallel-view-config', JSON.stringify({
|
||||
enabled,
|
||||
layout,
|
||||
panes,
|
||||
scrollSync
|
||||
}))
|
||||
}, [enabled, layout, panes, scrollSync])
|
||||
|
||||
const addPane = (config: Partial<PaneConfig>) => {
|
||||
const newPane: PaneConfig = {
|
||||
id: `pane-${Date.now()}`,
|
||||
versionId: config.versionId || 'kjv',
|
||||
visible: true,
|
||||
width: 100 / (panes.length + 1),
|
||||
locked: false,
|
||||
...config
|
||||
}
|
||||
|
||||
// Adjust existing pane widths
|
||||
const adjustedPanes = panes.map(p => ({
|
||||
...p,
|
||||
width: p.width * (panes.length / (panes.length + 1))
|
||||
}))
|
||||
|
||||
setPanes([...adjustedPanes, newPane])
|
||||
}
|
||||
|
||||
const removePane = (paneId: string) => {
|
||||
const updatedPanes = panes.filter(p => p.id !== paneId)
|
||||
// Redistribute widths
|
||||
const equalWidth = 100 / updatedPanes.length
|
||||
setPanes(updatedPanes.map(p => ({ ...p, width: equalWidth })))
|
||||
}
|
||||
|
||||
const updatePane = (paneId: string, updates: Partial<PaneConfig>) => {
|
||||
setPanes(panes.map(p =>
|
||||
p.id === paneId ? { ...p, ...updates } : p
|
||||
))
|
||||
}
|
||||
|
||||
const swapVersions = (paneId1: string, paneId2: string) => {
|
||||
const pane1 = panes.find(p => p.id === paneId1)
|
||||
const pane2 = panes.find(p => p.id === paneId2)
|
||||
|
||||
if (pane1 && pane2) {
|
||||
updatePane(paneId1, { versionId: pane2.versionId })
|
||||
updatePane(paneId2, { versionId: pane1.versionId })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ParallelViewContext.Provider value={{
|
||||
enabled,
|
||||
layout,
|
||||
panes,
|
||||
scrollSync,
|
||||
alignmentConfig,
|
||||
diffConfig,
|
||||
toggleParallelView: () => setEnabled(!enabled),
|
||||
addPane,
|
||||
removePane,
|
||||
updatePane,
|
||||
setLayout: setLayoutState,
|
||||
updateScrollSync: (config) => setScrollSync({ ...scrollSync, ...config }),
|
||||
swapVersions
|
||||
}}>
|
||||
{children}
|
||||
</ParallelViewContext.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Main Container Component
|
||||
|
||||
```typescript
|
||||
// ParallelViewContainer.tsx
|
||||
export const ParallelViewContainer: React.FC = () => {
|
||||
const { enabled, layout, panes, scrollSync } = useParallelView()
|
||||
const scrollSynchronizer = useRef(new ScrollSynchronizer(scrollSync))
|
||||
|
||||
if (!enabled || panes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visiblePanes = panes.filter(p => p.visible)
|
||||
|
||||
const getGridTemplate = () => {
|
||||
switch (layout.layout) {
|
||||
case '2-pane-horizontal':
|
||||
return 'repeat(2, 1fr)'
|
||||
case '3-pane':
|
||||
return 'repeat(3, 1fr)'
|
||||
case '4-pane':
|
||||
return 'repeat(2, 1fr)'
|
||||
default:
|
||||
return '1fr'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="parallel-view-container"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: getGridTemplate(),
|
||||
gap: layout.showDividers ? 1 : 0,
|
||||
height: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{visiblePanes.map((pane, index) => (
|
||||
<React.Fragment key={pane.id}>
|
||||
<Pane
|
||||
config={pane}
|
||||
onScroll={(scrollTop) => {
|
||||
if (scrollSync.enabled) {
|
||||
scrollSynchronizer.current.syncScroll(
|
||||
document.getElementById(`pane-${pane.id}`)!,
|
||||
scrollTop
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{layout.showDividers && index < visiblePanes.length - 1 && (
|
||||
<PaneDivider
|
||||
leftPaneId={visiblePanes[index].id}
|
||||
rightPaneId={visiblePanes[index + 1].id}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Data Persistence
|
||||
|
||||
### LocalStorage Schema
|
||||
```typescript
|
||||
interface ParallelViewStorage {
|
||||
version: number
|
||||
enabled: boolean
|
||||
layout: LayoutConfig
|
||||
panes: PaneConfig[]
|
||||
scrollSync: ScrollSyncConfig
|
||||
alignmentConfig: AlignmentConfig
|
||||
diffConfig: DiffConfig
|
||||
recentVersionCombinations: string[][] // Track popular combos
|
||||
}
|
||||
|
||||
// Key: 'bible-reader:parallel-view'
|
||||
```
|
||||
|
||||
### User Preferences API
|
||||
```typescript
|
||||
// Add to UserPreference model
|
||||
model UserPreference {
|
||||
// ... existing fields
|
||||
parallelViewConfig Json?
|
||||
favoriteVersionCombinations Json? // [["kjv", "esv"], ["niv", "msg"]]
|
||||
}
|
||||
|
||||
// API endpoint
|
||||
POST /api/user/preferences/parallel-view
|
||||
Body: ParallelViewStorage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Timeline
|
||||
|
||||
### Week 1: Core Functionality
|
||||
**Day 1-2: Foundation**
|
||||
- [ ] Create context provider
|
||||
- [ ] Build basic 2-pane layout
|
||||
- [ ] Implement version selector per pane
|
||||
- [ ] Add layout switcher (1/2/3 panes)
|
||||
|
||||
**Day 3-4: Scroll Sync**
|
||||
- [ ] Implement scroll synchronizer
|
||||
- [ ] Add verse-based sync
|
||||
- [ ] Add pixel-based sync
|
||||
- [ ] Test smooth scrolling
|
||||
|
||||
**Day 5: Resizing & Controls**
|
||||
- [ ] Build resizable dividers
|
||||
- [ ] Add width adjustment
|
||||
- [ ] Implement swap versions
|
||||
- [ ] Test on different screen sizes
|
||||
|
||||
**Deliverable:** Working parallel view with basic features
|
||||
|
||||
### Week 2: Advanced Features & Polish
|
||||
**Day 1-2: Alignment & Diff**
|
||||
- [ ] Implement verse alignment highlighting
|
||||
- [ ] Build diff view
|
||||
- [ ] Add similarity calculations
|
||||
- [ ] Test with various translations
|
||||
|
||||
**Day 3-4: Mobile & Responsive**
|
||||
- [ ] Design mobile layout (tabs)
|
||||
- [ ] Implement swipe navigation
|
||||
- [ ] Optimize for tablets
|
||||
- [ ] Test touch gestures
|
||||
|
||||
**Day 5: Polish & Testing**
|
||||
- [ ] Independent highlighting per pane
|
||||
- [ ] Performance optimization
|
||||
- [ ] Bug fixes
|
||||
- [ ] Documentation
|
||||
|
||||
**Deliverable:** Production-ready parallel Bible view
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Plan
|
||||
|
||||
### Pre-Launch Checklist
|
||||
- [ ] All layouts tested (2/3/4 pane)
|
||||
- [ ] Scroll sync working smoothly
|
||||
- [ ] Mobile responsive design complete
|
||||
- [ ] Performance benchmarks met (<100ms lag)
|
||||
- [ ] Accessibility audit passed
|
||||
- [ ] Cross-browser testing complete
|
||||
- [ ] User documentation created
|
||||
|
||||
### Rollout Strategy
|
||||
1. **Beta (Week 1)**: 10% of users, 2-pane only
|
||||
2. **Staged (Week 2)**: 50% of users, all layouts
|
||||
3. **Full (Week 3)**: 100% of users
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes & Considerations
|
||||
|
||||
### Performance
|
||||
- Use virtual scrolling for long chapters
|
||||
- Debounce scroll sync (avoid jank)
|
||||
- Lazy load panes not in viewport
|
||||
- Cache rendered verses
|
||||
- Monitor memory usage with multiple panes
|
||||
|
||||
### Accessibility
|
||||
- Maintain keyboard navigation across panes
|
||||
- Screen reader support for pane switching
|
||||
- Focus management between panes
|
||||
- ARIA labels for all controls
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Owner:** Development Team
|
||||
**Status:** Ready for Implementation
|
||||
907
PAYLOAD_AUTH_MIGRATION_GUIDE.md
Normal file
907
PAYLOAD_AUTH_MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,907 @@
|
||||
# Payload CMS Authentication Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides detailed steps for migrating from the current JWT-based authentication system to Payload CMS's built-in authentication system while maintaining backward compatibility and ensuring zero downtime.
|
||||
|
||||
## Current Authentication System Analysis
|
||||
|
||||
### Existing Implementation
|
||||
- **Technology**: Custom JWT implementation with bcryptjs
|
||||
- **Token Expiry**: 7 days
|
||||
- **Storage**: PostgreSQL (User, AdminUser tables)
|
||||
- **Roles**: USER, ADMIN, SUPER_ADMIN
|
||||
- **Session Management**: Stateless JWT tokens
|
||||
|
||||
### Current Auth Flow
|
||||
```mermaid
|
||||
graph LR
|
||||
A[User Login] --> B[Validate Credentials]
|
||||
B --> C[Generate JWT]
|
||||
C --> D[Return Token]
|
||||
D --> E[Store in LocalStorage]
|
||||
E --> F[Include in Headers]
|
||||
F --> G[Verify on Each Request]
|
||||
```
|
||||
|
||||
## Payload Authentication System
|
||||
|
||||
### Key Features
|
||||
- **Cookie-based sessions** with HTTP-only cookies
|
||||
- **CSRF protection** built-in
|
||||
- **Refresh tokens** for extended sessions
|
||||
- **Password reset flow** with email verification
|
||||
- **Two-factor authentication** support (optional)
|
||||
- **OAuth providers** integration capability
|
||||
|
||||
### Payload Auth Flow
|
||||
```mermaid
|
||||
graph LR
|
||||
A[User Login] --> B[Validate Credentials]
|
||||
B --> C[Create Session]
|
||||
C --> D[Set HTTP-only Cookie]
|
||||
D --> E[Return User Data]
|
||||
E --> F[Auto-include Cookie]
|
||||
F --> G[Session Validation]
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Dual Authentication Support
|
||||
|
||||
#### Step 1.1: Configure Payload Auth
|
||||
```typescript
|
||||
// config/auth.config.ts
|
||||
export const authConfig = {
|
||||
// Enable both JWT and session-based auth
|
||||
cookies: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
domain: process.env.COOKIE_DOMAIN,
|
||||
},
|
||||
tokenExpiration: 604800, // 7 days (matching current)
|
||||
maxLoginAttempts: 5,
|
||||
lockTime: 600000, // 10 minutes
|
||||
|
||||
// Custom JWT for backward compatibility
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET,
|
||||
expiresIn: '7d',
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
secret: process.env.SESSION_SECRET,
|
||||
cookie: {
|
||||
maxAge: 604800000, // 7 days in milliseconds
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### Step 1.2: Create Compatibility Layer
|
||||
```typescript
|
||||
// lib/auth/compatibility.ts
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PayloadRequest } from 'payload/types';
|
||||
|
||||
export class AuthCompatibilityLayer {
|
||||
/**
|
||||
* Validates both old JWT tokens and new Payload sessions
|
||||
*/
|
||||
static async validateRequest(req: PayloadRequest) {
|
||||
// Check for Payload session first
|
||||
if (req.user) {
|
||||
return { valid: true, user: req.user, method: 'payload' };
|
||||
}
|
||||
|
||||
// Check for legacy JWT token
|
||||
const token = this.extractJWT(req);
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
|
||||
const user = await this.getUserFromToken(decoded);
|
||||
return { valid: true, user, method: 'jwt' };
|
||||
} catch (error) {
|
||||
return { valid: false, error: 'Invalid token' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false, error: 'No authentication provided' };
|
||||
}
|
||||
|
||||
private static extractJWT(req: PayloadRequest): string | null {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async getUserFromToken(decoded: any) {
|
||||
// Fetch user from Payload collections
|
||||
const user = await payload.findByID({
|
||||
collection: 'users',
|
||||
id: decoded.userId,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates both JWT (for legacy) and creates Payload session
|
||||
*/
|
||||
static async createDualAuth(user: any, req: PayloadRequest) {
|
||||
// Create Payload session
|
||||
const payloadToken = await payload.login({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
},
|
||||
req,
|
||||
});
|
||||
|
||||
// Generate legacy JWT
|
||||
const jwtToken = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
return {
|
||||
payloadToken,
|
||||
jwtToken, // For backward compatibility
|
||||
user,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: User Migration
|
||||
|
||||
#### Step 2.1: User Data Migration Script
|
||||
```typescript
|
||||
// scripts/migrate-users.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import payload from 'payload';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface MigrationResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ email: string; error: string }>;
|
||||
}
|
||||
|
||||
export async function migrateUsers(): Promise<MigrationResult> {
|
||||
const result: MigrationResult = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Fetch all users from Prisma
|
||||
const users = await prisma.user.findMany({
|
||||
include: {
|
||||
subscription: true,
|
||||
userSettings: true,
|
||||
bookmarks: true,
|
||||
highlights: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Starting migration of ${users.length} users...`);
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// Check if user already exists in Payload
|
||||
const existing = await payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: { equals: user.email },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing.docs.length > 0) {
|
||||
console.log(`User ${user.email} already migrated, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create user in Payload
|
||||
const payloadUser = await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
|
||||
// Password handling - already hashed
|
||||
password: user.password,
|
||||
_verified: true, // Mark as verified
|
||||
|
||||
// Custom fields
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
favoriteVersion: user.favoriteVersion || 'VDC',
|
||||
|
||||
// Settings
|
||||
profileSettings: {
|
||||
fontSize: user.userSettings?.fontSize || 16,
|
||||
theme: user.userSettings?.theme || 'light',
|
||||
showVerseNumbers: user.userSettings?.showVerseNumbers ?? true,
|
||||
enableNotifications: user.userSettings?.enableNotifications ?? true,
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
lastLogin: user.lastLogin,
|
||||
},
|
||||
});
|
||||
|
||||
// Migrate related data
|
||||
if (user.subscription) {
|
||||
await migrateUserSubscription(payloadUser.id, user.subscription);
|
||||
}
|
||||
|
||||
if (user.bookmarks.length > 0) {
|
||||
await migrateUserBookmarks(payloadUser.id, user.bookmarks);
|
||||
}
|
||||
|
||||
if (user.highlights.length > 0) {
|
||||
await migrateUserHighlights(payloadUser.id, user.highlights);
|
||||
}
|
||||
|
||||
result.success++;
|
||||
console.log(`✓ Migrated user: ${user.email}`);
|
||||
|
||||
} catch (error) {
|
||||
result.failed++;
|
||||
result.errors.push({
|
||||
email: user.email,
|
||||
error: error.message,
|
||||
});
|
||||
console.error(`✗ Failed to migrate user ${user.email}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function migrateUserSubscription(userId: string, subscription: any) {
|
||||
await payload.create({
|
||||
collection: 'subscriptions',
|
||||
data: {
|
||||
user: userId,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
planName: subscription.planName,
|
||||
status: subscription.status,
|
||||
currentPeriodStart: subscription.currentPeriodStart,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
conversationCount: subscription.conversationCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function migrateUserBookmarks(userId: string, bookmarks: any[]) {
|
||||
for (const bookmark of bookmarks) {
|
||||
await payload.create({
|
||||
collection: 'bookmarks',
|
||||
data: {
|
||||
user: userId,
|
||||
book: bookmark.book,
|
||||
chapter: bookmark.chapter,
|
||||
verse: bookmark.verse,
|
||||
note: bookmark.note,
|
||||
createdAt: bookmark.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateUserHighlights(userId: string, highlights: any[]) {
|
||||
for (const highlight of highlights) {
|
||||
await payload.create({
|
||||
collection: 'highlights',
|
||||
data: {
|
||||
user: userId,
|
||||
verseId: highlight.verseId,
|
||||
color: highlight.color,
|
||||
note: highlight.note,
|
||||
createdAt: highlight.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2.2: Password Migration Strategy
|
||||
|
||||
Since passwords are already hashed with bcrypt, we have three options:
|
||||
|
||||
**Option 1: Direct Hash Migration (Recommended)**
|
||||
```typescript
|
||||
// hooks/auth.hooks.ts
|
||||
export const passwordValidationHook = {
|
||||
beforeOperation: async ({ args, operation }) => {
|
||||
if (operation === 'login') {
|
||||
const { email, password } = args.data;
|
||||
|
||||
// Find user
|
||||
const user = await payload.find({
|
||||
collection: 'users',
|
||||
where: { email: { equals: email } },
|
||||
});
|
||||
|
||||
if (user.docs.length === 0) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const userDoc = user.docs[0];
|
||||
|
||||
// Check if password needs rehashing (migrated user)
|
||||
if (userDoc.passwordMigrated) {
|
||||
// Use bcrypt directly for migrated passwords
|
||||
const valid = await bcrypt.compare(password, userDoc.password);
|
||||
if (!valid) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// Rehash with Payload's method on successful login
|
||||
await payload.update({
|
||||
collection: 'users',
|
||||
id: userDoc.id,
|
||||
data: {
|
||||
password, // Will be hashed by Payload
|
||||
passwordMigrated: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Option 2: Password Reset Campaign**
|
||||
```typescript
|
||||
// scripts/password-reset-campaign.ts
|
||||
export async function sendPasswordResetToMigratedUsers() {
|
||||
const migratedUsers = await payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
passwordMigrated: { equals: true },
|
||||
},
|
||||
});
|
||||
|
||||
for (const user of migratedUsers.docs) {
|
||||
const token = await payload.forgotPassword({
|
||||
collection: 'users',
|
||||
data: { email: user.email },
|
||||
disableEmail: false,
|
||||
});
|
||||
|
||||
// Send custom email explaining migration
|
||||
await sendMigrationEmail({
|
||||
to: user.email,
|
||||
token,
|
||||
userName: user.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: API Endpoint Migration
|
||||
|
||||
#### Step 3.1: Update Frontend API Calls
|
||||
```typescript
|
||||
// lib/api/auth.ts (Frontend)
|
||||
export class AuthAPI {
|
||||
private static baseURL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
static async login(email: string, password: string) {
|
||||
try {
|
||||
// Try new Payload endpoint first
|
||||
const response = await fetch(`${this.baseURL}/api/users/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // Important for cookies
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Store JWT for backward compatibility if provided
|
||||
if (data.token) {
|
||||
localStorage.setItem('token', data.token);
|
||||
}
|
||||
|
||||
return { success: true, user: data.user };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payload login failed, trying legacy...', error);
|
||||
}
|
||||
|
||||
// Fallback to legacy endpoint
|
||||
return this.legacyLogin(email, password);
|
||||
}
|
||||
|
||||
private static async legacyLogin(email: string, password: string) {
|
||||
const response = await fetch(`${this.baseURL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
localStorage.setItem('token', data.token);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async logout() {
|
||||
// Clear both Payload session and JWT
|
||||
await fetch(`${this.baseURL}/api/users/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
static async getMe() {
|
||||
// Try Payload endpoint with cookie
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/api/users/me`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to JWT
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
const response = await fetch(`${this.baseURL}/api/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3.2: Update API Middleware
|
||||
```typescript
|
||||
// middleware/auth.middleware.ts
|
||||
import { PayloadRequest } from 'payload/types';
|
||||
import { AuthCompatibilityLayer } from '../lib/auth/compatibility';
|
||||
|
||||
export async function authMiddleware(req: PayloadRequest, res: any, next: any) {
|
||||
const auth = await AuthCompatibilityLayer.validateRequest(req);
|
||||
|
||||
if (!auth.valid) {
|
||||
return res.status(401).json({ error: auth.error });
|
||||
}
|
||||
|
||||
// Attach user to request for both auth methods
|
||||
req.user = auth.user;
|
||||
req.authMethod = auth.method; // Track which auth method was used
|
||||
|
||||
// Log for monitoring during migration
|
||||
console.log(`Auth method: ${auth.method} for user: ${auth.user.email}`);
|
||||
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Testing & Validation
|
||||
|
||||
#### Step 4.1: Authentication Test Suite
|
||||
```typescript
|
||||
// tests/auth/migration.test.ts
|
||||
import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
|
||||
import payload from 'payload';
|
||||
import { testUser } from '../fixtures/users';
|
||||
|
||||
describe('Authentication Migration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
await payload.init({
|
||||
local: true,
|
||||
secret: 'test-secret',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dual Authentication', () => {
|
||||
it('should accept legacy JWT tokens', async () => {
|
||||
const token = generateLegacyJWT(testUser);
|
||||
const response = await fetch('/api/protected', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should accept Payload session cookies', async () => {
|
||||
const loginResponse = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const cookie = loginResponse.headers.get('set-cookie');
|
||||
|
||||
const response = await fetch('/api/protected', {
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should migrate password on first login', async () => {
|
||||
const migratedUser = await createMigratedUser();
|
||||
|
||||
const response = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: migratedUser.email,
|
||||
password: 'original-password',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Check that password was rehashed
|
||||
const user = await payload.findByID({
|
||||
collection: 'users',
|
||||
id: migratedUser.id,
|
||||
});
|
||||
|
||||
expect(user.passwordMigrated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
it('should maintain session across requests', async () => {
|
||||
const session = await createAuthSession(testUser);
|
||||
|
||||
// Make multiple requests with same session
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const response = await fetch('/api/protected', {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('should refresh token before expiry', async () => {
|
||||
const session = await createAuthSession(testUser);
|
||||
|
||||
// Fast-forward time to near expiry
|
||||
jest.advanceTimersByTime(6 * 24 * 60 * 60 * 1000); // 6 days
|
||||
|
||||
const response = await fetch('/api/users/refresh', {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const newCookie = response.headers.get('set-cookie');
|
||||
expect(newCookie).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role-Based Access', () => {
|
||||
it('should enforce admin access', async () => {
|
||||
const regularUser = await createUser({ role: 'USER' });
|
||||
const adminUser = await createUser({ role: 'ADMIN' });
|
||||
|
||||
const regularSession = await createAuthSession(regularUser);
|
||||
const adminSession = await createAuthSession(adminUser);
|
||||
|
||||
// Regular user should be denied
|
||||
const regularResponse = await fetch('/api/admin/users', {
|
||||
headers: { 'Cookie': regularSession.cookie },
|
||||
});
|
||||
expect(regularResponse.status).toBe(403);
|
||||
|
||||
// Admin should be allowed
|
||||
const adminResponse = await fetch('/api/admin/users', {
|
||||
headers: { 'Cookie': adminSession.cookie },
|
||||
});
|
||||
expect(adminResponse.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Step 4.2: Migration Validation Script
|
||||
```typescript
|
||||
// scripts/validate-migration.ts
|
||||
export async function validateMigration() {
|
||||
const report = {
|
||||
users: { total: 0, migrated: 0, failed: [] },
|
||||
auth: { jwt: 0, payload: 0, dual: 0 },
|
||||
subscriptions: { total: 0, active: 0, cancelled: 0 },
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Check user migration
|
||||
const prismaUsers = await prisma.user.count();
|
||||
const payloadUsers = await payload.count({ collection: 'users' });
|
||||
|
||||
report.users.total = prismaUsers;
|
||||
report.users.migrated = payloadUsers.totalDocs;
|
||||
|
||||
// Test authentication methods
|
||||
const testResults = await testAuthenticationMethods();
|
||||
report.auth = testResults;
|
||||
|
||||
// Validate subscriptions
|
||||
const subscriptions = await validateSubscriptions();
|
||||
report.subscriptions = subscriptions;
|
||||
|
||||
// Generate report
|
||||
console.log('Migration Validation Report:');
|
||||
console.log('============================');
|
||||
console.log(`Users: ${report.users.migrated}/${report.users.total} migrated`);
|
||||
console.log(`Auth Methods: JWT: ${report.auth.jwt}, Payload: ${report.auth.payload}`);
|
||||
console.log(`Subscriptions: ${report.subscriptions.active} active`);
|
||||
|
||||
if (report.errors.length > 0) {
|
||||
console.log('\nErrors found:');
|
||||
report.errors.forEach(error => console.error(error));
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Gradual Rollout
|
||||
|
||||
#### Step 5.1: Feature Flags
|
||||
```typescript
|
||||
// lib/features/flags.ts
|
||||
export const AuthFeatureFlags = {
|
||||
USE_PAYLOAD_AUTH: process.env.NEXT_PUBLIC_USE_PAYLOAD_AUTH === 'true',
|
||||
DUAL_AUTH_MODE: process.env.NEXT_PUBLIC_DUAL_AUTH === 'true',
|
||||
FORCE_PASSWORD_RESET: process.env.FORCE_PASSWORD_RESET === 'true',
|
||||
};
|
||||
|
||||
// Usage in components
|
||||
export function LoginForm() {
|
||||
const handleSubmit = async (data: LoginData) => {
|
||||
if (AuthFeatureFlags.USE_PAYLOAD_AUTH) {
|
||||
return payloadLogin(data);
|
||||
} else if (AuthFeatureFlags.DUAL_AUTH_MODE) {
|
||||
return dualLogin(data);
|
||||
} else {
|
||||
return legacyLogin(data);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 5.2: A/B Testing
|
||||
```typescript
|
||||
// lib/ab-testing/auth.ts
|
||||
export function getAuthStrategy(userId?: string): 'legacy' | 'payload' | 'dual' {
|
||||
// Percentage-based rollout
|
||||
const rolloutPercentage = parseInt(process.env.PAYLOAD_AUTH_ROLLOUT || '0');
|
||||
|
||||
if (!userId) {
|
||||
// New users always get Payload auth
|
||||
return 'payload';
|
||||
}
|
||||
|
||||
// Consistent assignment based on user ID
|
||||
const hash = hashUserId(userId);
|
||||
const bucket = hash % 100;
|
||||
|
||||
if (bucket < rolloutPercentage) {
|
||||
return 'payload';
|
||||
} else if (process.env.DUAL_AUTH === 'true') {
|
||||
return 'dual';
|
||||
} else {
|
||||
return 'legacy';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 6: Monitoring & Observability
|
||||
|
||||
#### Step 6.1: Authentication Metrics
|
||||
```typescript
|
||||
// lib/monitoring/auth-metrics.ts
|
||||
import { metrics } from '@opentelemetry/api-metrics';
|
||||
|
||||
export class AuthMetrics {
|
||||
private meter = metrics.getMeter('auth-migration');
|
||||
private loginCounter = this.meter.createCounter('auth_login_total');
|
||||
private methodHistogram = this.meter.createHistogram('auth_method_duration');
|
||||
private failureCounter = this.meter.createCounter('auth_failure_total');
|
||||
|
||||
trackLogin(method: 'jwt' | 'payload' | 'dual', success: boolean, duration: number) {
|
||||
this.loginCounter.add(1, {
|
||||
method,
|
||||
success: success.toString(),
|
||||
});
|
||||
|
||||
this.methodHistogram.record(duration, {
|
||||
method,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
this.failureCounter.add(1, { method });
|
||||
}
|
||||
}
|
||||
|
||||
async generateReport() {
|
||||
return {
|
||||
totalLogins: await this.getTotalLogins(),
|
||||
methodDistribution: await this.getMethodDistribution(),
|
||||
failureRate: await this.getFailureRate(),
|
||||
avgDuration: await this.getAverageDuration(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 6.2: Monitoring Dashboard
|
||||
```typescript
|
||||
// components/admin/AuthMigrationDashboard.tsx
|
||||
export function AuthMigrationDashboard() {
|
||||
const [metrics, setMetrics] = useState<AuthMetrics>();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetrics = async () => {
|
||||
const data = await fetch('/api/admin/auth-metrics').then(r => r.json());
|
||||
setMetrics(data);
|
||||
};
|
||||
|
||||
fetchMetrics();
|
||||
const interval = setInterval(fetchMetrics, 30000); // Update every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<h2>Authentication Migration Status</h2>
|
||||
|
||||
<div className="metrics-grid">
|
||||
<MetricCard
|
||||
title="Auth Method Distribution"
|
||||
value={
|
||||
<PieChart data={[
|
||||
{ name: 'JWT', value: metrics?.jwt || 0 },
|
||||
{ name: 'Payload', value: metrics?.payload || 0 },
|
||||
{ name: 'Dual', value: metrics?.dual || 0 },
|
||||
]} />
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Migration Progress"
|
||||
value={`${metrics?.migratedUsers || 0} / ${metrics?.totalUsers || 0}`}
|
||||
subtitle={`${Math.round((metrics?.migratedUsers / metrics?.totalUsers) * 100)}% complete`}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Auth Success Rate"
|
||||
value={`${metrics?.successRate || 0}%`}
|
||||
trend={metrics?.successTrend}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Active Sessions"
|
||||
value={metrics?.activeSessions || 0}
|
||||
subtitle={`JWT: ${metrics?.jwtSessions}, Payload: ${metrics?.payloadSessions}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="recent-issues">
|
||||
<h3>Recent Authentication Issues</h3>
|
||||
<IssuesList issues={metrics?.recentIssues || []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Rollback Procedures
|
||||
|
||||
### Emergency Rollback Script
|
||||
```typescript
|
||||
// scripts/auth-rollback.ts
|
||||
export async function rollbackAuth() {
|
||||
console.log('Starting authentication rollback...');
|
||||
|
||||
// 1. Disable Payload auth endpoints
|
||||
await updateEnvironmentVariable('USE_PAYLOAD_AUTH', 'false');
|
||||
|
||||
// 2. Re-enable legacy endpoints
|
||||
await updateEnvironmentVariable('USE_LEGACY_AUTH', 'true');
|
||||
|
||||
// 3. Clear Payload sessions
|
||||
await payload.delete({
|
||||
collection: 'sessions',
|
||||
where: {},
|
||||
});
|
||||
|
||||
// 4. Notify users
|
||||
await sendSystemNotification({
|
||||
message: 'Authentication system maintenance in progress',
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
// 5. Monitor legacy auth performance
|
||||
startLegacyAuthMonitoring();
|
||||
|
||||
console.log('Rollback complete. Legacy auth restored.');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices & Recommendations
|
||||
|
||||
### Security Considerations
|
||||
1. **Never log passwords** in any form
|
||||
2. **Use HTTPS only** for production
|
||||
3. **Implement rate limiting** on auth endpoints
|
||||
4. **Monitor failed login attempts**
|
||||
5. **Regular security audits** of auth flows
|
||||
|
||||
### Performance Optimization
|
||||
1. **Cache user sessions** in Redis
|
||||
2. **Implement session pooling**
|
||||
3. **Use database indexes** on email fields
|
||||
4. **Lazy-load user relationships**
|
||||
5. **CDN for static auth assets**
|
||||
|
||||
### User Experience
|
||||
1. **Transparent migration** - users shouldn't notice
|
||||
2. **Clear error messages** for auth failures
|
||||
3. **Password strength indicators**
|
||||
4. **Remember me functionality**
|
||||
5. **Social login options** (future enhancement)
|
||||
|
||||
## Conclusion
|
||||
|
||||
The migration to Payload CMS authentication provides:
|
||||
- **Enhanced security** with HTTP-only cookies and CSRF protection
|
||||
- **Better session management** with automatic refresh
|
||||
- **Simplified codebase** with less custom auth code
|
||||
- **Future-proof architecture** for OAuth and 2FA
|
||||
|
||||
The dual-authentication approach ensures zero downtime and allows for gradual migration with full rollback capability.
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0*
|
||||
*Last Updated: November 2024*
|
||||
*Author: Biblical Guide Development Team*
|
||||
1093
PAYLOAD_CMS_IMPLEMENTATION_PLAN.md
Normal file
1093
PAYLOAD_CMS_IMPLEMENTATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
649
PAYLOAD_IMPLEMENTATION_ROADMAP.md
Normal file
649
PAYLOAD_IMPLEMENTATION_ROADMAP.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# Payload CMS Implementation Roadmap
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Project Name**: Biblical Guide Backend Migration to Payload CMS
|
||||
**Duration**: 12 Weeks (3 Months)
|
||||
**Start Date**: TBD
|
||||
**Budget**: ~$40,000
|
||||
**Team Size**: 4 developers
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This roadmap outlines the complete migration of Biblical Guide from a custom Prisma/Next.js backend to Payload CMS, encompassing authentication, payments, content management, and API services.
|
||||
|
||||
### Key Deliverables
|
||||
1. ✅ Fully functional Payload CMS backend
|
||||
2. ✅ Migrated user authentication system
|
||||
3. ✅ Integrated Stripe payment processing
|
||||
4. ✅ Complete data migration from PostgreSQL
|
||||
5. ✅ Admin panel with enhanced features
|
||||
6. ✅ Zero-downtime deployment
|
||||
|
||||
## Project Phases
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title Payload CMS Implementation Timeline
|
||||
dateFormat YYYY-MM-DD
|
||||
section Phase 1
|
||||
Setup & Config :a1, 2024-12-01, 14d
|
||||
Environment Prep :a2, after a1, 7d
|
||||
|
||||
section Phase 2
|
||||
Data Models :b1, after a2, 14d
|
||||
Collections Setup :b2, after b1, 7d
|
||||
|
||||
section Phase 3
|
||||
Auth Migration :c1, after b2, 14d
|
||||
User Migration :c2, after c1, 7d
|
||||
|
||||
section Phase 4
|
||||
Payment Integration :d1, after c2, 14d
|
||||
Webhook Setup :d2, after d1, 7d
|
||||
|
||||
section Phase 5
|
||||
API Migration :e1, after d2, 14d
|
||||
Frontend Updates :e2, after e1, 7d
|
||||
|
||||
section Phase 6
|
||||
Testing & QA :f1, after e2, 14d
|
||||
Deployment :f2, after f1, 7d
|
||||
```
|
||||
|
||||
## Week-by-Week Breakdown
|
||||
|
||||
### Week 1-2: Foundation Setup
|
||||
|
||||
#### Week 1: Environment & Initial Setup
|
||||
**Owner**: Lead Developer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Install Payload CMS in existing Next.js app | Running Payload instance |
|
||||
| Tue | Configure PostgreSQL adapter | Database connection established |
|
||||
| Wed | Set up development environment | Docker compose file |
|
||||
| Thu | Configure TypeScript & build tools | Type generation working |
|
||||
| Fri | Initial admin panel setup | Access to Payload admin |
|
||||
|
||||
#### Week 2: Infrastructure & CI/CD
|
||||
**Owner**: DevOps Engineer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Set up staging environment | Staging server running |
|
||||
| Tue | Configure GitHub Actions | CI/CD pipeline |
|
||||
| Wed | Set up monitoring (Sentry, DataDog) | Monitoring dashboard |
|
||||
| Thu | Configure backup strategies | Automated backups |
|
||||
| Fri | Document deployment process | Deployment guide |
|
||||
|
||||
**Milestone 1**: ✅ Payload CMS running in development and staging
|
||||
|
||||
### Week 3-4: Data Model Migration
|
||||
|
||||
#### Week 3: Core Collections
|
||||
**Owner**: Backend Developer
|
||||
|
||||
```typescript
|
||||
// Collections to implement this week
|
||||
const week3Collections = [
|
||||
'users', // User authentication
|
||||
'subscriptions', // Subscription management
|
||||
'products', // Stripe products
|
||||
'prices', // Stripe prices
|
||||
'customers', // Stripe customers
|
||||
];
|
||||
```
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Create Users collection with auth | User model complete |
|
||||
| Tue | Create Subscriptions collection | Subscription model complete |
|
||||
| Wed | Create Products & Prices collections | Product models complete |
|
||||
| Thu | Create Customers collection | Customer model complete |
|
||||
| Fri | Test relationships & validations | All models validated |
|
||||
|
||||
#### Week 4: Bible & Content Collections
|
||||
**Owner**: Backend Developer
|
||||
|
||||
```typescript
|
||||
// Collections to implement this week
|
||||
const week4Collections = [
|
||||
'bible-books', // Bible book metadata
|
||||
'bible-verses', // Bible verse content
|
||||
'bookmarks', // User bookmarks
|
||||
'highlights', // User highlights
|
||||
'prayers', // Prayer content
|
||||
'reading-plans', // Reading plan definitions
|
||||
];
|
||||
```
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Create Bible Books collection | Bible structure ready |
|
||||
| Tue | Create Bible Verses collection | Verse storage ready |
|
||||
| Wed | Create Bookmarks & Highlights | User features ready |
|
||||
| Thu | Create Prayers & Reading Plans | Content features ready |
|
||||
| Fri | Import Bible data | Bible content migrated |
|
||||
|
||||
**Milestone 2**: ✅ All data models implemented and validated
|
||||
|
||||
### Week 5-6: Authentication System
|
||||
|
||||
#### Week 5: Auth Implementation
|
||||
**Owner**: Full-stack Developer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Implement dual auth support | Compatibility layer |
|
||||
| Tue | Configure JWT backward compatibility | Legacy auth working |
|
||||
| Wed | Set up Payload sessions | Cookie-based auth |
|
||||
| Thu | Implement password migration | Password handling ready |
|
||||
| Fri | Create auth middleware | Auth pipeline complete |
|
||||
|
||||
#### Week 6: User Migration
|
||||
**Owner**: Backend Developer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Write user migration script | Migration script ready |
|
||||
| Tue | Test migration with sample data | Validation complete |
|
||||
| Wed | Migrate development users | Dev users migrated |
|
||||
| Thu | Migrate staging users | Staging users migrated |
|
||||
| Fri | Validate auth flows | All auth methods tested |
|
||||
|
||||
**Milestone 3**: ✅ Authentication system fully operational
|
||||
|
||||
### Week 7-8: Payment Integration
|
||||
|
||||
#### Week 7: Stripe Setup
|
||||
**Owner**: Backend Developer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Install Stripe plugin | Plugin configured |
|
||||
| Tue | Configure webhook handlers | Webhooks ready |
|
||||
| Wed | Create checkout endpoints | Checkout API ready |
|
||||
| Thu | Implement subscription management | Subscription API ready |
|
||||
| Fri | Test payment flows | Payments working |
|
||||
|
||||
#### Week 8: Payment Migration
|
||||
**Owner**: Full-stack Developer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Migrate existing subscriptions | Subscriptions migrated |
|
||||
| Tue | Update frontend components | UI components ready |
|
||||
| Wed | Test renewal flows | Renewals working |
|
||||
| Thu | Test cancellation flows | Cancellations working |
|
||||
| Fri | Validate webhook processing | All webhooks tested |
|
||||
|
||||
**Milestone 4**: ✅ Payment system fully integrated
|
||||
|
||||
### Week 9-10: API & Frontend Updates
|
||||
|
||||
#### Week 9: API Migration
|
||||
**Owner**: Full-stack Developer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Map existing API endpoints | API mapping complete |
|
||||
| Tue | Implement custom endpoints | Custom APIs ready |
|
||||
| Wed | Update API documentation | Docs updated |
|
||||
| Thu | Test API compatibility | APIs validated |
|
||||
| Fri | Performance optimization | APIs optimized |
|
||||
|
||||
#### Week 10: Frontend Integration
|
||||
**Owner**: Frontend Developer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Update API client libraries | Clients updated |
|
||||
| Tue | Modify authentication flow | Auth UI updated |
|
||||
| Wed | Update subscription components | Payment UI ready |
|
||||
| Thu | Test user workflows | Workflows validated |
|
||||
| Fri | Fix UI/UX issues | Frontend polished |
|
||||
|
||||
**Milestone 5**: ✅ Complete system integration achieved
|
||||
|
||||
### Week 11: Testing & Quality Assurance
|
||||
|
||||
#### Comprehensive Testing Plan
|
||||
**Owner**: QA Engineer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Unit testing (Collections) | Unit tests passing |
|
||||
| Tue | Integration testing (APIs) | Integration tests passing |
|
||||
| Wed | E2E testing (User flows) | E2E tests passing |
|
||||
| Thu | Performance testing | Performance validated |
|
||||
| Fri | Security audit | Security report |
|
||||
|
||||
#### Test Coverage Requirements
|
||||
```javascript
|
||||
// Minimum test coverage targets
|
||||
const testCoverage = {
|
||||
unit: 80, // 80% unit test coverage
|
||||
integration: 70, // 70% integration test coverage
|
||||
e2e: 60, // 60% E2E test coverage
|
||||
overall: 75, // 75% overall coverage
|
||||
};
|
||||
```
|
||||
|
||||
**Milestone 6**: ✅ All tests passing, system ready for production
|
||||
|
||||
### Week 12: Deployment & Go-Live
|
||||
|
||||
#### Production Deployment
|
||||
**Owner**: DevOps Engineer + Lead Developer
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|------------|
|
||||
| Mon | Final data migration dry run | Migration validated |
|
||||
| Tue | Production environment setup | Production ready |
|
||||
| Wed | Deploy Payload CMS | System deployed |
|
||||
| Thu | DNS & routing updates | Traffic routing ready |
|
||||
| Fri | Go-live & monitoring | System live |
|
||||
|
||||
**Milestone 7**: ✅ Successfully deployed to production
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Infrastructure Requirements
|
||||
|
||||
```yaml
|
||||
# Production Infrastructure
|
||||
production:
|
||||
servers:
|
||||
- type: application
|
||||
count: 2
|
||||
specs:
|
||||
cpu: 4 vCPUs
|
||||
ram: 16GB
|
||||
storage: 100GB SSD
|
||||
|
||||
- type: database
|
||||
count: 1 (+ 1 replica)
|
||||
specs:
|
||||
cpu: 8 vCPUs
|
||||
ram: 32GB
|
||||
storage: 500GB SSD
|
||||
|
||||
services:
|
||||
- PostgreSQL 15
|
||||
- Redis 7 (caching)
|
||||
- CloudFlare (CDN)
|
||||
- Stripe (payments)
|
||||
- Mailgun (email)
|
||||
- Sentry (monitoring)
|
||||
```
|
||||
|
||||
### Development Tools
|
||||
|
||||
```json
|
||||
{
|
||||
"required_tools": {
|
||||
"ide": "VS Code with Payload extension",
|
||||
"node": "20.x LTS",
|
||||
"npm": "10.x",
|
||||
"docker": "24.x",
|
||||
"git": "2.x"
|
||||
},
|
||||
"recommended_tools": {
|
||||
"api_testing": "Postman/Insomnia",
|
||||
"db_client": "TablePlus/pgAdmin",
|
||||
"monitoring": "Datadog/New Relic"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Risk Management
|
||||
|
||||
### Risk Matrix
|
||||
|
||||
| Risk | Probability | Impact | Mitigation Strategy |
|
||||
|------|------------|--------|-------------------|
|
||||
| Data loss during migration | Low | Critical | Multiple backups, dry runs, rollback plan |
|
||||
| Authentication issues | Medium | High | Dual auth support, gradual rollout |
|
||||
| Payment disruption | Low | Critical | Parallel systems, thorough testing |
|
||||
| Performance degradation | Medium | Medium | Load testing, caching, optimization |
|
||||
| User experience disruption | Medium | High | Feature flags, A/B testing |
|
||||
| Timeline overrun | Medium | Medium | Buffer time, parallel workstreams |
|
||||
|
||||
### Contingency Plans
|
||||
|
||||
#### Plan A: Gradual Migration (Recommended)
|
||||
- Run both systems in parallel
|
||||
- Migrate users in batches
|
||||
- Feature flag controlled rollout
|
||||
- 4-week transition period
|
||||
|
||||
#### Plan B: Big Bang Migration
|
||||
- Complete migration over weekend
|
||||
- All users migrated at once
|
||||
- Higher risk but faster
|
||||
- Requires extensive testing
|
||||
|
||||
#### Plan C: Rollback Procedure
|
||||
```bash
|
||||
# Emergency rollback script
|
||||
#!/bin/bash
|
||||
|
||||
# 1. Switch DNS to old system
|
||||
update_dns_records "old-system"
|
||||
|
||||
# 2. Restore database from backup
|
||||
restore_database "pre-migration-backup"
|
||||
|
||||
# 3. Disable Payload endpoints
|
||||
disable_payload_routes
|
||||
|
||||
# 4. Re-enable legacy system
|
||||
enable_legacy_system
|
||||
|
||||
# 5. Notify team and users
|
||||
send_notifications "rollback-complete"
|
||||
```
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Technical Metrics
|
||||
|
||||
| Metric | Target | Measurement Method |
|
||||
|--------|--------|-------------------|
|
||||
| API Response Time | < 200ms (p95) | DataDog APM |
|
||||
| Database Query Time | < 50ms | PostgreSQL logs |
|
||||
| Page Load Time | < 2 seconds | Google PageSpeed |
|
||||
| Error Rate | < 0.1% | Sentry monitoring |
|
||||
| Uptime | 99.9% | UptimeRobot |
|
||||
|
||||
### Business Metrics
|
||||
|
||||
| Metric | Target | Measurement Method |
|
||||
|--------|--------|-------------------|
|
||||
| User Retention | > 95% | Analytics dashboard |
|
||||
| Conversion Rate | > 3% | Stripe dashboard |
|
||||
| Support Tickets | -30% | Help desk system |
|
||||
| Admin Efficiency | +40% | Time tracking |
|
||||
| Content Publishing | +50% | CMS metrics |
|
||||
|
||||
### Migration Success Criteria
|
||||
|
||||
✅ **Must Have**
|
||||
- Zero data loss
|
||||
- All users successfully migrated
|
||||
- Payment processing operational
|
||||
- Authentication working
|
||||
- Core features functional
|
||||
|
||||
✅ **Should Have**
|
||||
- Performance improvements
|
||||
- Enhanced admin features
|
||||
- Better error handling
|
||||
- Improved monitoring
|
||||
|
||||
✅ **Nice to Have**
|
||||
- New feature additions
|
||||
- UI/UX improvements
|
||||
- Advanced analytics
|
||||
|
||||
## Team Structure & Responsibilities
|
||||
|
||||
### Core Team
|
||||
|
||||
| Role | Name | Responsibilities | Allocation |
|
||||
|------|------|-----------------|------------|
|
||||
| Project Manager | TBD | Overall coordination, stakeholder communication | 50% |
|
||||
| Lead Developer | TBD | Architecture decisions, code reviews | 100% |
|
||||
| Backend Developer | TBD | Collections, APIs, migrations | 100% |
|
||||
| Frontend Developer | TBD | UI components, user experience | 75% |
|
||||
| DevOps Engineer | TBD | Infrastructure, deployment, monitoring | 50% |
|
||||
| QA Engineer | TBD | Testing, validation, quality assurance | 50% |
|
||||
|
||||
### RACI Matrix
|
||||
|
||||
| Task | Project Manager | Lead Dev | Backend Dev | Frontend Dev | DevOps | QA |
|
||||
|------|----------------|----------|-------------|--------------|--------|-----|
|
||||
| Architecture Design | I | R/A | C | C | C | I |
|
||||
| Collections Development | I | A | R | I | I | C |
|
||||
| API Development | I | A | R | C | I | C |
|
||||
| Frontend Updates | I | A | I | R | I | C |
|
||||
| Testing | C | A | C | C | I | R |
|
||||
| Deployment | A | C | I | I | R | C |
|
||||
|
||||
*R = Responsible, A = Accountable, C = Consulted, I = Informed*
|
||||
|
||||
## Communication Plan
|
||||
|
||||
### Regular Meetings
|
||||
|
||||
| Meeting | Frequency | Participants | Purpose |
|
||||
|---------|-----------|-------------|---------|
|
||||
| Daily Standup | Daily | All team | Progress updates |
|
||||
| Sprint Planning | Bi-weekly | All team | Plan next sprint |
|
||||
| Technical Review | Weekly | Dev team | Architecture decisions |
|
||||
| Stakeholder Update | Weekly | PM + Stakeholders | Progress report |
|
||||
| Retrospective | Bi-weekly | All team | Process improvement |
|
||||
|
||||
### Communication Channels
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
immediate:
|
||||
tool: Slack
|
||||
channels:
|
||||
- "#payload-migration"
|
||||
- "#payload-alerts"
|
||||
|
||||
async:
|
||||
tool: GitHub
|
||||
uses:
|
||||
- Pull requests
|
||||
- Issues
|
||||
- Discussions
|
||||
|
||||
documentation:
|
||||
tool: Confluence/Notion
|
||||
sections:
|
||||
- Technical specs
|
||||
- Meeting notes
|
||||
- Decision log
|
||||
```
|
||||
|
||||
## Budget Breakdown
|
||||
|
||||
### Development Costs
|
||||
|
||||
| Item | Hours | Rate | Cost |
|
||||
|------|-------|------|------|
|
||||
| Lead Developer | 480 | $150/hr | $72,000 |
|
||||
| Backend Developer | 480 | $120/hr | $57,600 |
|
||||
| Frontend Developer | 360 | $100/hr | $36,000 |
|
||||
| DevOps Engineer | 240 | $130/hr | $31,200 |
|
||||
| QA Engineer | 240 | $90/hr | $21,600 |
|
||||
| Project Manager | 240 | $110/hr | $26,400 |
|
||||
| **Subtotal** | | | **$244,800** |
|
||||
|
||||
### Infrastructure Costs (Annual)
|
||||
|
||||
| Service | Monthly | Annual |
|
||||
|---------|---------|--------|
|
||||
| Servers (AWS/GCP) | $800 | $9,600 |
|
||||
| Database (PostgreSQL) | $400 | $4,800 |
|
||||
| Redis Cache | $150 | $1,800 |
|
||||
| CloudFlare | $200 | $2,400 |
|
||||
| Monitoring (DataDog) | $300 | $3,600 |
|
||||
| Backup Storage | $100 | $1,200 |
|
||||
| **Total** | **$1,950** | **$23,400** |
|
||||
|
||||
### Third-Party Services (Annual)
|
||||
|
||||
| Service | Monthly | Annual |
|
||||
|---------|---------|--------|
|
||||
| Stripe Fees | ~$500 | ~$6,000 |
|
||||
| Mailgun | $35 | $420 |
|
||||
| Sentry | $26 | $312 |
|
||||
| **Total** | **$561** | **$6,732** |
|
||||
|
||||
### Total Project Cost
|
||||
|
||||
```
|
||||
Development (one-time): $244,800
|
||||
Infrastructure (annual): $23,400
|
||||
Services (annual): $6,732
|
||||
Contingency (20%): $48,960
|
||||
━━━━━━━━━━━━━━━━━━━━━
|
||||
Total First Year: $323,892
|
||||
Annual Recurring: $30,132
|
||||
```
|
||||
|
||||
## Post-Launch Plan
|
||||
|
||||
### Week 1 Post-Launch
|
||||
- 24/7 monitoring with on-call rotation
|
||||
- Daily health checks
|
||||
- Immediate bug fixes
|
||||
- User feedback collection
|
||||
|
||||
### Week 2-4 Post-Launch
|
||||
- Performance optimization
|
||||
- Minor feature adjustments
|
||||
- Documentation updates
|
||||
- Team knowledge transfer
|
||||
|
||||
### Month 2-3 Post-Launch
|
||||
- Feature enhancements
|
||||
- Advanced admin training
|
||||
- Process optimization
|
||||
- Success metrics review
|
||||
|
||||
### Ongoing Maintenance
|
||||
- Regular security updates
|
||||
- Performance monitoring
|
||||
- Feature development
|
||||
- User support
|
||||
|
||||
## Training & Documentation
|
||||
|
||||
### Documentation Deliverables
|
||||
|
||||
1. **Technical Documentation**
|
||||
- API reference guide
|
||||
- Database schema documentation
|
||||
- Deployment procedures
|
||||
- Troubleshooting guide
|
||||
|
||||
2. **User Documentation**
|
||||
- Admin user guide
|
||||
- Content management guide
|
||||
- Video tutorials
|
||||
- FAQ section
|
||||
|
||||
3. **Developer Documentation**
|
||||
- Code architecture guide
|
||||
- Collection development guide
|
||||
- Plugin development guide
|
||||
- Testing procedures
|
||||
|
||||
### Training Plan
|
||||
|
||||
| Audience | Duration | Topics | Format |
|
||||
|----------|----------|--------|--------|
|
||||
| Developers | 2 days | Payload development, APIs, deployment | Workshop |
|
||||
| Admins | 1 day | Content management, user management | Hands-on |
|
||||
| Support Team | 4 hours | Common issues, escalation | Presentation |
|
||||
| End Users | Self-serve | New features, changes | Video/Docs |
|
||||
|
||||
## Quality Gates
|
||||
|
||||
### Gate 1: Development Complete (Week 10)
|
||||
- [ ] All collections implemented
|
||||
- [ ] APIs functional
|
||||
- [ ] Frontend integrated
|
||||
- [ ] Documentation complete
|
||||
|
||||
### Gate 2: Testing Complete (Week 11)
|
||||
- [ ] All tests passing
|
||||
- [ ] Performance validated
|
||||
- [ ] Security audit passed
|
||||
- [ ] UAT sign-off
|
||||
|
||||
### Gate 3: Production Ready (Week 12)
|
||||
- [ ] Infrastructure provisioned
|
||||
- [ ] Data migration tested
|
||||
- [ ] Rollback plan validated
|
||||
- [ ] Team trained
|
||||
|
||||
### Gate 4: Go-Live Approval
|
||||
- [ ] Stakeholder approval
|
||||
- [ ] Risk assessment complete
|
||||
- [ ] Communication sent
|
||||
- [ ] Support ready
|
||||
|
||||
## Appendices
|
||||
|
||||
### A. Technology Stack
|
||||
|
||||
```javascript
|
||||
const techStack = {
|
||||
framework: "Next.js 15.5.3",
|
||||
cms: "Payload CMS 2.x",
|
||||
database: "PostgreSQL 15",
|
||||
orm: "Payload ORM (Drizzle)",
|
||||
cache: "Redis 7",
|
||||
payments: "Stripe",
|
||||
email: "Mailgun",
|
||||
hosting: "Vercel/AWS",
|
||||
cdn: "CloudFlare",
|
||||
monitoring: "Sentry + DataDog",
|
||||
languages: {
|
||||
backend: "TypeScript",
|
||||
frontend: "TypeScript + React",
|
||||
database: "SQL",
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### B. Key Contacts
|
||||
|
||||
| Role | Name | Email | Phone |
|
||||
|------|------|-------|-------|
|
||||
| Product Owner | TBD | - | - |
|
||||
| Technical Lead | TBD | - | - |
|
||||
| Stripe Support | - | support@stripe.com | - |
|
||||
| Payload Support | - | support@payloadcms.com | - |
|
||||
|
||||
### C. Useful Resources
|
||||
|
||||
- [Payload CMS Documentation](https://payloadcms.com/docs)
|
||||
- [Stripe API Reference](https://stripe.com/docs/api)
|
||||
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
|
||||
- [Next.js Documentation](https://nextjs.org/docs)
|
||||
- [Project GitHub Repository](https://github.com/your-org/biblical-guide)
|
||||
|
||||
### D. Monitoring Dashboards
|
||||
|
||||
- **Application Monitoring**: `https://app.datadoghq.com/dashboard/biblical-guide`
|
||||
- **Error Tracking**: `https://sentry.io/organizations/biblical-guide`
|
||||
- **Payment Analytics**: `https://dashboard.stripe.com`
|
||||
- **Traffic Analytics**: `https://dash.cloudflare.com`
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
This roadmap has been reviewed and approved by:
|
||||
|
||||
| Name | Role | Signature | Date |
|
||||
|------|------|-----------|------|
|
||||
| | Product Owner | | |
|
||||
| | Technical Lead | | |
|
||||
| | Project Manager | | |
|
||||
| | Finance Manager | | |
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0*
|
||||
*Last Updated: November 2024*
|
||||
*Next Review: December 2024*
|
||||
*Status: DRAFT - Pending Approval*
|
||||
1524
PAYLOAD_PAYMENT_INTEGRATION_GUIDE.md
Normal file
1524
PAYLOAD_PAYMENT_INTEGRATION_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
866
RICH_TEXT_NOTES_PLAN.md
Normal file
866
RICH_TEXT_NOTES_PLAN.md
Normal file
@@ -0,0 +1,866 @@
|
||||
# Rich Text Study Notes - Implementation Plan
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement a comprehensive rich text note-taking system allowing users to create detailed, formatted study notes with images, links, and advanced organization features for deep Bible study.
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🟡 Medium
|
||||
**Estimated Time:** 2 weeks (80 hours)
|
||||
**Target Completion:** TBD
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals & Objectives
|
||||
|
||||
### Primary Goals
|
||||
1. Provide rich text editing capabilities for study notes
|
||||
2. Enable advanced formatting (bold, italic, lists, headers)
|
||||
3. Support multimedia content (images, links, videos)
|
||||
4. Organize notes with folders and tags
|
||||
5. Enable search and filtering across all notes
|
||||
|
||||
### User Value Proposition
|
||||
- **For students**: Comprehensive study journal
|
||||
- **For scholars**: Research documentation
|
||||
- **For teachers**: Lesson planning and preparation
|
||||
- **For small groups**: Collaborative study materials
|
||||
- **For personal growth**: Spiritual reflection journal
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Specifications
|
||||
|
||||
### 1. Note Data Model
|
||||
|
||||
```typescript
|
||||
interface StudyNote {
|
||||
id: string
|
||||
userId: string
|
||||
|
||||
// Content
|
||||
title: string
|
||||
content: string // Rich text (HTML or JSON)
|
||||
contentType: 'html' | 'json' | 'markdown'
|
||||
plainText: string // For search indexing
|
||||
|
||||
// References
|
||||
verseReferences: VerseReference[]
|
||||
relatedNotes: string[] // Note IDs
|
||||
|
||||
// Organization
|
||||
folderId: string | null
|
||||
tags: string[]
|
||||
color: string // For visual organization
|
||||
isPinned: boolean
|
||||
isFavorite: boolean
|
||||
|
||||
// Collaboration
|
||||
visibility: 'private' | 'shared' | 'public'
|
||||
sharedWith: string[] // User IDs
|
||||
|
||||
// Metadata
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
lastViewedAt: Date
|
||||
version: number // For version history
|
||||
wordCount: number
|
||||
readingTime: number // minutes
|
||||
}
|
||||
|
||||
interface NoteFolder {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
description?: string
|
||||
parentId: string | null // For nested folders
|
||||
color: string
|
||||
icon: string
|
||||
order: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
interface VerseReference {
|
||||
book: string
|
||||
chapter: number
|
||||
verse: number
|
||||
endVerse?: number
|
||||
context?: string // Surrounding text snippet
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Rich Text Editor (TipTap)
|
||||
|
||||
```typescript
|
||||
import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
|
||||
// Custom verse reference extension
|
||||
const VerseReference = Node.create({
|
||||
name: 'verseReference',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
book: { default: null },
|
||||
chapter: { default: null },
|
||||
verse: { default: null },
|
||||
text: { default: null }
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-verse-ref]' }]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
'span',
|
||||
{
|
||||
...HTMLAttributes,
|
||||
'data-verse-ref': true,
|
||||
class: 'verse-reference-chip',
|
||||
contenteditable: 'false'
|
||||
},
|
||||
node.attrs.text || `${node.attrs.book} ${node.attrs.chapter}:${node.attrs.verse}`
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
interface NoteEditorProps {
|
||||
note: StudyNote
|
||||
onSave: (content: string) => void
|
||||
autoSave?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export const NoteEditor: React.FC<NoteEditorProps> = ({
|
||||
note,
|
||||
onSave,
|
||||
autoSave = true,
|
||||
readOnly = false
|
||||
}) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3, 4] },
|
||||
code: { HTMLAttributes: { class: 'code-block' } }
|
||||
}),
|
||||
Highlight.configure({ multicolor: true }),
|
||||
Typography,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { class: 'prose-link' }
|
||||
}),
|
||||
Image.configure({
|
||||
inline: true,
|
||||
HTMLAttributes: { class: 'note-image' }
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true
|
||||
}),
|
||||
Table.configure({ resizable: true }),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Placeholder.configure({
|
||||
placeholder: 'Start writing your study notes...',
|
||||
showOnlyWhenEditable: true
|
||||
}),
|
||||
VerseReference
|
||||
],
|
||||
content: note.content,
|
||||
editable: !readOnly,
|
||||
autofocus: !readOnly,
|
||||
onUpdate: ({ editor }) => {
|
||||
if (autoSave) {
|
||||
debouncedSave(editor.getHTML())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const debouncedSave = useDebounce((content: string) => {
|
||||
onSave(content)
|
||||
}, 1000)
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<Box className="note-editor">
|
||||
{!readOnly && <EditorToolbar editor={editor} />}
|
||||
<EditorContent editor={editor} />
|
||||
<EditorFooter editor={editor} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Editor Toolbar
|
||||
|
||||
```typescript
|
||||
const EditorToolbar: React.FC<{ editor: Editor }> = ({ editor }) => {
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
|
||||
const [imageDialogOpen, setImageDialogOpen] = useState(false)
|
||||
const [verseRefDialogOpen, setVerseRefDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Box className="editor-toolbar" sx={{
|
||||
p: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.5
|
||||
}}>
|
||||
{/* Text Formatting */}
|
||||
<ButtonGroup size="small">
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
color={editor.isActive('bold') ? 'primary' : 'default'}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<FormatBoldIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
color={editor.isActive('italic') ? 'primary' : 'default'}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<FormatItalicIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
color={editor.isActive('underline') ? 'primary' : 'default'}
|
||||
title="Underline (Ctrl+U)"
|
||||
>
|
||||
<FormatUnderlinedIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
color={editor.isActive('strike') ? 'primary' : 'default'}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<FormatStrikethroughIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* Headings */}
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={
|
||||
editor.isActive('heading', { level: 1 }) ? 'h1' :
|
||||
editor.isActive('heading', { level: 2 }) ? 'h2' :
|
||||
editor.isActive('heading', { level: 3 }) ? 'h3' :
|
||||
editor.isActive('paragraph') ? 'p' : 'p'
|
||||
}
|
||||
onChange={(e) => {
|
||||
const level = e.target.value
|
||||
if (level === 'p') {
|
||||
editor.chain().focus().setParagraph().run()
|
||||
} else {
|
||||
const headingLevel = parseInt(level.substring(1)) as 1 | 2 | 3
|
||||
editor.chain().focus().setHeading({ level: headingLevel }).run()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="p">Paragraph</MenuItem>
|
||||
<MenuItem value="h1">Heading 1</MenuItem>
|
||||
<MenuItem value="h2">Heading 2</MenuItem>
|
||||
<MenuItem value="h3">Heading 3</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* Lists */}
|
||||
<ButtonGroup size="small">
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
color={editor.isActive('bulletList') ? 'primary' : 'default'}
|
||||
title="Bullet List"
|
||||
>
|
||||
<FormatListBulletedIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
color={editor.isActive('orderedList') ? 'primary' : 'default'}
|
||||
title="Numbered List"
|
||||
>
|
||||
<FormatListNumberedIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||
color={editor.isActive('taskList') ? 'primary' : 'default'}
|
||||
title="Task List"
|
||||
>
|
||||
<CheckBoxIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* Alignment */}
|
||||
<ButtonGroup size="small">
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
color={editor.isActive({ textAlign: 'left' }) ? 'primary' : 'default'}
|
||||
title="Align Left"
|
||||
>
|
||||
<FormatAlignLeftIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
color={editor.isActive({ textAlign: 'center' }) ? 'primary' : 'default'}
|
||||
title="Align Center"
|
||||
>
|
||||
<FormatAlignCenterIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
color={editor.isActive({ textAlign: 'right' }) ? 'primary' : 'default'}
|
||||
title="Align Right"
|
||||
>
|
||||
<FormatAlignRightIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* Highlights */}
|
||||
<HighlightColorPicker
|
||||
editor={editor}
|
||||
onSelect={(color) => {
|
||||
editor.chain().focus().toggleHighlight({ color }).run()
|
||||
}}
|
||||
/>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* Media & References */}
|
||||
<ButtonGroup size="small">
|
||||
<IconButton
|
||||
onClick={() => setLinkDialogOpen(true)}
|
||||
color={editor.isActive('link') ? 'primary' : 'default'}
|
||||
title="Insert Link"
|
||||
>
|
||||
<LinkIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => setImageDialogOpen(true)}
|
||||
title="Insert Image"
|
||||
>
|
||||
<ImageIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => setVerseRefDialogOpen(true)}
|
||||
title="Insert Verse Reference"
|
||||
>
|
||||
<MenuBookIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
color={editor.isActive('codeBlock') ? 'primary' : 'default'}
|
||||
title="Code Block"
|
||||
>
|
||||
<CodeIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<ButtonGroup size="small">
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<UndoIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<RedoIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
{/* Dialogs */}
|
||||
<LinkDialog
|
||||
open={linkDialogOpen}
|
||||
onClose={() => setLinkDialogOpen(false)}
|
||||
onInsert={(url, text) => {
|
||||
editor.chain().focus().setLink({ href: url }).insertContent(text).run()
|
||||
}}
|
||||
/>
|
||||
|
||||
<ImageDialog
|
||||
open={imageDialogOpen}
|
||||
onClose={() => setImageDialogOpen(false)}
|
||||
onInsert={(url, alt) => {
|
||||
editor.chain().focus().setImage({ src: url, alt }).run()
|
||||
}}
|
||||
/>
|
||||
|
||||
<VerseReferenceDialog
|
||||
open={verseRefDialogOpen}
|
||||
onClose={() => setVerseRefDialogOpen(false)}
|
||||
onInsert={(ref) => {
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'verseReference',
|
||||
attrs: {
|
||||
book: ref.book,
|
||||
chapter: ref.chapter,
|
||||
verse: ref.verse,
|
||||
text: `${ref.book} ${ref.chapter}:${ref.verse}`
|
||||
}
|
||||
}).run()
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Notes List & Organization
|
||||
|
||||
```typescript
|
||||
const NotesPage: React.FC = () => {
|
||||
const [notes, setNotes] = useState<StudyNote[]>([])
|
||||
const [folders, setFolders] = useState<NoteFolder[]>([])
|
||||
const [selectedFolder, setSelectedFolder] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'updated' | 'created' | 'title'>('updated')
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'compact'>('list')
|
||||
|
||||
// Load notes
|
||||
useEffect(() => {
|
||||
loadNotes()
|
||||
}, [selectedFolder, searchQuery, sortBy])
|
||||
|
||||
const loadNotes = async () => {
|
||||
const params = new URLSearchParams({
|
||||
...(selectedFolder && { folderId: selectedFolder }),
|
||||
...(searchQuery && { search: searchQuery }),
|
||||
sortBy
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/notes?${params}`)
|
||||
const data = await response.json()
|
||||
setNotes(data.notes)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh' }}>
|
||||
{/* Sidebar - Folders */}
|
||||
<Box sx={{ width: 250, borderRight: 1, borderColor: 'divider', p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Study Notes
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => createNewNote()}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
New Note
|
||||
</Button>
|
||||
|
||||
<List>
|
||||
<ListItem
|
||||
button
|
||||
selected={selectedFolder === null}
|
||||
onClick={() => setSelectedFolder(null)}
|
||||
>
|
||||
<ListItemIcon><AllInboxIcon /></ListItemIcon>
|
||||
<ListItemText primary="All Notes" secondary={notes.length} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem button>
|
||||
<ListItemIcon><StarIcon /></ListItemIcon>
|
||||
<ListItemText primary="Favorites" />
|
||||
</ListItem>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<ListSubheader>Folders</ListSubheader>
|
||||
|
||||
{folders.map(folder => (
|
||||
<ListItem
|
||||
key={folder.id}
|
||||
button
|
||||
selected={selectedFolder === folder.id}
|
||||
onClick={() => setSelectedFolder(folder.id)}
|
||||
sx={{ pl: 3 }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<FolderIcon style={{ color: folder.color }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={folder.name} />
|
||||
</ListItem>
|
||||
))}
|
||||
|
||||
<ListItem button onClick={() => createFolder()}>
|
||||
<ListItemIcon><AddIcon /></ListItemIcon>
|
||||
<ListItemText primary="New Folder" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Main Content - Notes List */}
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Toolbar */}
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Box display="flex" gap={2} alignItems="center">
|
||||
<TextField
|
||||
placeholder="Search notes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: <SearchIcon />
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<Select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
|
||||
<MenuItem value="updated">Last Updated</MenuItem>
|
||||
<MenuItem value="created">Date Created</MenuItem>
|
||||
<MenuItem value="title">Title</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setViewMode(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="list"><ViewListIcon /></ToggleButton>
|
||||
<ToggleButton value="grid"><ViewModuleIcon /></ToggleButton>
|
||||
<ToggleButton value="compact"><ViewHeadlineIcon /></ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Notes Display */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||||
{viewMode === 'grid' ? (
|
||||
<Grid container spacing={2}>
|
||||
{notes.map(note => (
|
||||
<Grid item key={note.id} xs={12} sm={6} md={4}>
|
||||
<NoteCard note={note} onClick={() => openNote(note)} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<List>
|
||||
{notes.map(note => (
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
compact={viewMode === 'compact'}
|
||||
onClick={() => openNote(note)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Note Templates
|
||||
|
||||
```typescript
|
||||
const NOTE_TEMPLATES = [
|
||||
{
|
||||
id: 'sermon-notes',
|
||||
name: 'Sermon Notes',
|
||||
icon: '📝',
|
||||
content: `
|
||||
<h1>Sermon Notes</h1>
|
||||
<p><strong>Date:</strong> </p>
|
||||
<p><strong>Speaker:</strong> </p>
|
||||
<p><strong>Topic:</strong> </p>
|
||||
<h2>Main Points</h2>
|
||||
<ol>
|
||||
<li></li>
|
||||
<li></li>
|
||||
<li></li>
|
||||
</ol>
|
||||
<h2>Key Verses</h2>
|
||||
<p></p>
|
||||
<h2>Personal Application</h2>
|
||||
<p></p>
|
||||
<h2>Prayer Points</h2>
|
||||
<ul>
|
||||
<li></li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'bible-study',
|
||||
name: 'Bible Study',
|
||||
icon: '📖',
|
||||
content: `
|
||||
<h1>Bible Study</h1>
|
||||
<h2>Passage</h2>
|
||||
<p></p>
|
||||
<h2>Context</h2>
|
||||
<p><strong>Historical Context:</strong> </p>
|
||||
<p><strong>Literary Context:</strong> </p>
|
||||
<h2>Observation</h2>
|
||||
<ul>
|
||||
<li>What does the text say?</li>
|
||||
</ul>
|
||||
<h2>Interpretation</h2>
|
||||
<ul>
|
||||
<li>What does it mean?</li>
|
||||
</ul>
|
||||
<h2>Application</h2>
|
||||
<ul>
|
||||
<li>How does this apply to my life?</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'character-study',
|
||||
name: 'Character Study',
|
||||
icon: '👤',
|
||||
content: `
|
||||
<h1>Character Study: [Name]</h1>
|
||||
<h2>Background</h2>
|
||||
<p><strong>Family:</strong> </p>
|
||||
<p><strong>Occupation:</strong> </p>
|
||||
<p><strong>Time Period:</strong> </p>
|
||||
<h2>Key Events</h2>
|
||||
<ol>
|
||||
<li></li>
|
||||
</ol>
|
||||
<h2>Character Traits</h2>
|
||||
<ul>
|
||||
<li><strong>Strengths:</strong> </li>
|
||||
<li><strong>Weaknesses:</strong> </li>
|
||||
</ul>
|
||||
<h2>Lessons Learned</h2>
|
||||
<p></p>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'topical-study',
|
||||
name: 'Topical Study',
|
||||
icon: '🏷️',
|
||||
content: `
|
||||
<h1>Topical Study: [Topic]</h1>
|
||||
<h2>Definition</h2>
|
||||
<p></p>
|
||||
<h2>Key Verses</h2>
|
||||
<ul>
|
||||
<li></li>
|
||||
</ul>
|
||||
<h2>What the Bible Says</h2>
|
||||
<p></p>
|
||||
<h2>Practical Application</h2>
|
||||
<p></p>
|
||||
`
|
||||
}
|
||||
]
|
||||
|
||||
const TemplateSelector: React.FC<{
|
||||
onSelect: (template: string) => void
|
||||
}> = ({ onSelect }) => {
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
{NOTE_TEMPLATES.map(template => (
|
||||
<Grid item key={template.id} xs={12} sm={6} md={4}>
|
||||
<Card
|
||||
sx={{ cursor: 'pointer', '&:hover': { boxShadow: 4 } }}
|
||||
onClick={() => onSelect(template.content)}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h4" textAlign="center" mb={1}>
|
||||
{template.icon}
|
||||
</Typography>
|
||||
<Typography variant="h6" textAlign="center">
|
||||
{template.name}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Full-Text Search
|
||||
|
||||
```typescript
|
||||
// API endpoint with PostgreSQL full-text search
|
||||
export async function POST(request: Request) {
|
||||
const { query } = await request.json()
|
||||
const userId = await getUserIdFromAuth(request)
|
||||
|
||||
const notes = await prisma.$queryRaw`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
"plainText",
|
||||
ts_rank(to_tsvector('english', title || ' ' || "plainText"), plainto_tsquery('english', ${query})) AS rank
|
||||
FROM "StudyNote"
|
||||
WHERE
|
||||
"userId" = ${userId}
|
||||
AND to_tsvector('english', title || ' ' || "plainText") @@ plainto_tsquery('english', ${query})
|
||||
ORDER BY rank DESC
|
||||
LIMIT 50
|
||||
`
|
||||
|
||||
return NextResponse.json({ notes })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
```prisma
|
||||
model StudyNote {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
title String
|
||||
content String @db.Text
|
||||
contentType String @default("html")
|
||||
plainText String @db.Text // For search
|
||||
|
||||
folderId String?
|
||||
folder NoteFolder? @relation(fields: [folderId], references: [id])
|
||||
|
||||
tags String[]
|
||||
color String?
|
||||
isPinned Boolean @default(false)
|
||||
isFavorite Boolean @default(false)
|
||||
|
||||
visibility String @default("private")
|
||||
sharedWith String[]
|
||||
|
||||
wordCount Int @default(0)
|
||||
readingTime Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastViewedAt DateTime @default(now())
|
||||
version Int @default(1)
|
||||
|
||||
verseReferences NoteVerseReference[]
|
||||
|
||||
@@index([userId, updatedAt])
|
||||
@@index([userId, folderId])
|
||||
@@index([userId, isPinned])
|
||||
}
|
||||
|
||||
model NoteFolder {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
name String
|
||||
description String?
|
||||
parentId String?
|
||||
parent NoteFolder? @relation("FolderHierarchy", fields: [parentId], references: [id])
|
||||
children NoteFolder[] @relation("FolderHierarchy")
|
||||
|
||||
color String @default("#1976d2")
|
||||
icon String @default("folder")
|
||||
order Int @default(0)
|
||||
|
||||
notes StudyNote[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, parentId])
|
||||
}
|
||||
|
||||
model NoteVerseReference {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
note StudyNote @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
|
||||
book String
|
||||
chapter Int
|
||||
verse Int
|
||||
endVerse Int?
|
||||
context String?
|
||||
|
||||
@@index([noteId])
|
||||
@@index([book, chapter, verse])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Timeline
|
||||
|
||||
### Week 1
|
||||
**Day 1-2:** Setup & Editor
|
||||
- [ ] Create database schema
|
||||
- [ ] Set up TipTap editor
|
||||
- [ ] Build basic toolbar
|
||||
|
||||
**Day 3-4:** Core Features
|
||||
- [ ] Implement save/autosave
|
||||
- [ ] Add formatting options
|
||||
- [ ] Build media insertion
|
||||
|
||||
**Day 5:** Organization
|
||||
- [ ] Create folders system
|
||||
- [ ] Add tags support
|
||||
- [ ] Implement search
|
||||
|
||||
### Week 2
|
||||
**Day 1-2:** Advanced Features
|
||||
- [ ] Build templates
|
||||
- [ ] Add verse references
|
||||
- [ ] Implement version history
|
||||
|
||||
**Day 3-4:** Polish
|
||||
- [ ] Mobile optimization
|
||||
- [ ] Performance tuning
|
||||
- [ ] UI refinement
|
||||
|
||||
**Day 5:** Testing & Launch
|
||||
- [ ] Bug fixes
|
||||
- [ ] Documentation
|
||||
- [ ] Deployment
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Status:** Ready for Implementation
|
||||
733
SPEED_READING_MODE_PLAN.md
Normal file
733
SPEED_READING_MODE_PLAN.md
Normal file
@@ -0,0 +1,733 @@
|
||||
# Speed Reading Mode - Implementation Plan
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement a speed reading mode using RSVP (Rapid Serial Visual Presentation) technique, allowing users to consume Bible content at accelerated rates while maintaining comprehension through guided visual training.
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🟡 Medium
|
||||
**Estimated Time:** 2 weeks (80 hours)
|
||||
**Target Completion:** TBD
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals & Objectives
|
||||
|
||||
### Primary Goals
|
||||
1. Enable users to read at 200-1000+ words per minute
|
||||
2. Reduce eye movement and increase focus
|
||||
3. Track reading speed progress over time
|
||||
4. Provide comprehension exercises
|
||||
5. Offer customizable display modes
|
||||
|
||||
### User Value Proposition
|
||||
- **For busy professionals**: Read more in less time
|
||||
- **For students**: Cover more material quickly
|
||||
- **For speed reading enthusiasts**: Practice technique
|
||||
- **For information seekers**: Rapid content consumption
|
||||
- **For skill builders**: Measurable improvement tracking
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Specifications
|
||||
|
||||
### 1. RSVP Configuration
|
||||
|
||||
```typescript
|
||||
interface RSVPConfig {
|
||||
// Speed
|
||||
wordsPerMinute: number // 200-1000+
|
||||
autoAdjust: boolean // Automatically adjust based on comprehension
|
||||
|
||||
// Display
|
||||
displayMode: 'single' | 'dual' | 'triple' // Words shown at once
|
||||
chunkSize: number // 1-3 words
|
||||
fontSize: number // 16-48px
|
||||
fontFamily: string
|
||||
backgroundColor: string
|
||||
textColor: string
|
||||
highlightColor: string
|
||||
|
||||
// Timing
|
||||
pauseOnPunctuation: boolean
|
||||
pauseDuration: { comma: number; period: number; question: number } // ms
|
||||
pauseBetweenVerses: number // ms
|
||||
|
||||
// Focus
|
||||
showFixationPoint: boolean
|
||||
fixationStyle: 'center' | 'orpAlgorithm' | 'custom'
|
||||
showWordPosition: boolean // Current word out of total
|
||||
showProgress: boolean
|
||||
|
||||
// Comprehension
|
||||
enableQuizzes: boolean
|
||||
quizFrequency: number // Every N verses
|
||||
requirePassToContinue: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### 2. RSVP Display Component
|
||||
|
||||
```typescript
|
||||
const RSVPReader: React.FC<{
|
||||
content: string[]
|
||||
config: RSVPConfig
|
||||
onComplete: () => void
|
||||
onPause: () => void
|
||||
}> = ({ content, config, onComplete, onPause }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [words, setWords] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
// Parse content into words
|
||||
const allWords = content.join(' ').split(/\s+/)
|
||||
setWords(allWords)
|
||||
}, [content])
|
||||
|
||||
// Main playback logic
|
||||
useEffect(() => {
|
||||
if (!isPlaying || currentIndex >= words.length) return
|
||||
|
||||
const currentWord = words[currentIndex]
|
||||
const delay = calculateDelay(currentWord, config)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentIndex(prev => prev + 1)
|
||||
|
||||
// Check if completed
|
||||
if (currentIndex + 1 >= words.length) {
|
||||
setIsPlaying(false)
|
||||
onComplete()
|
||||
}
|
||||
}, delay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [isPlaying, currentIndex, words, config])
|
||||
|
||||
const calculateDelay = (word: string, config: RSVPConfig): number => {
|
||||
const baseDelay = (60 / config.wordsPerMinute) * 1000
|
||||
|
||||
// Adjust for punctuation
|
||||
if (config.pauseOnPunctuation) {
|
||||
if (word.endsWith(',')) return baseDelay + config.pauseDuration.comma
|
||||
if (word.endsWith('.') || word.endsWith('!')) return baseDelay + config.pauseDuration.period
|
||||
if (word.endsWith('?')) return baseDelay + config.pauseDuration.question
|
||||
}
|
||||
|
||||
// Adjust for word length (longer words take slightly longer)
|
||||
const lengthMultiplier = 1 + (Math.max(0, word.length - 6) * 0.02)
|
||||
|
||||
return baseDelay * lengthMultiplier
|
||||
}
|
||||
|
||||
const getDisplayWords = (): string[] => {
|
||||
if (config.displayMode === 'single') {
|
||||
return [words[currentIndex]]
|
||||
} else if (config.displayMode === 'dual') {
|
||||
return [words[currentIndex], words[currentIndex + 1]].filter(Boolean)
|
||||
} else {
|
||||
return [words[currentIndex], words[currentIndex + 1], words[currentIndex + 2]].filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
const displayWords = getDisplayWords()
|
||||
const progress = (currentIndex / words.length) * 100
|
||||
|
||||
return (
|
||||
<Box className="rsvp-reader" sx={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: config.backgroundColor
|
||||
}}>
|
||||
{/* Header - Controls */}
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<SpeedReadingControls
|
||||
isPlaying={isPlaying}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => {
|
||||
setIsPlaying(false)
|
||||
onPause()
|
||||
}}
|
||||
onRestart={() => setCurrentIndex(0)}
|
||||
config={config}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Main Display Area */}
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Fixation Point Guide */}
|
||||
{config.showFixationPoint && (
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 0
|
||||
}}>
|
||||
<FixationGuide style={config.fixationStyle} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Word Display */}
|
||||
<Box sx={{
|
||||
fontSize: `${config.fontSize}px`,
|
||||
fontFamily: config.fontFamily,
|
||||
color: config.textColor,
|
||||
textAlign: 'center',
|
||||
minHeight: '100px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
zIndex: 1
|
||||
}}>
|
||||
{displayWords.map((word, index) => {
|
||||
const isActive = index === 0
|
||||
const fixationIndex = calculateFixationPoint(word)
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`${currentIndex}-${index}`}
|
||||
style={{
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
opacity: isActive ? 1 : 0.6,
|
||||
transition: 'opacity 0.1s ease'
|
||||
}}
|
||||
>
|
||||
{word.split('').map((char, charIndex) => (
|
||||
<span
|
||||
key={charIndex}
|
||||
style={{
|
||||
color: charIndex === fixationIndex && isActive
|
||||
? config.highlightColor
|
||||
: 'inherit',
|
||||
fontWeight: charIndex === fixationIndex && isActive
|
||||
? 800
|
||||
: 'inherit'
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Word Position Indicator */}
|
||||
{config.showWordPosition && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 4 }}>
|
||||
Word {currentIndex + 1} of {words.length}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer - Progress */}
|
||||
{config.showProgress && (
|
||||
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<LinearProgress variant="determinate" value={progress} sx={{ mb: 1 }} />
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Typography variant="caption">
|
||||
{Math.round(progress)}% Complete
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
{config.wordsPerMinute} WPM
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ORP (Optimal Recognition Point) Algorithm
|
||||
const calculateFixationPoint = (word: string): number => {
|
||||
const length = word.length
|
||||
if (length <= 1) return 0
|
||||
if (length <= 5) return 1
|
||||
if (length <= 9) return 2
|
||||
if (length <= 13) return 3
|
||||
return Math.floor(length * 0.3)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Speed Reading Controls
|
||||
|
||||
```typescript
|
||||
const SpeedReadingControls: React.FC<{
|
||||
isPlaying: boolean
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onRestart: () => void
|
||||
config: RSVPConfig
|
||||
}> = ({ isPlaying, onPlay, onPause, onRestart, config }) => {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
return (
|
||||
<Box display="flex" gap={2} alignItems="center">
|
||||
{/* Playback Controls */}
|
||||
<ButtonGroup>
|
||||
<IconButton onClick={onRestart} title="Restart">
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={isPlaying ? onPause : onPlay}
|
||||
color="primary"
|
||||
size="large"
|
||||
>
|
||||
{isPlaying ? <PauseIcon /> : <PlayArrowIcon />}
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
{/* Speed Adjustment */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 200 }}>
|
||||
<IconButton size="small" onClick={() => adjustSpeed(-25)}>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ flex: 1, textAlign: 'center' }}>
|
||||
<Typography variant="body2" fontWeight="600">
|
||||
{config.wordsPerMinute} WPM
|
||||
</Typography>
|
||||
<Slider
|
||||
value={config.wordsPerMinute}
|
||||
onChange={(_, value) => updateSpeed(value as number)}
|
||||
min={100}
|
||||
max={1000}
|
||||
step={25}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => adjustSpeed(25)}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Quick Speed Presets */}
|
||||
<ButtonGroup size="small">
|
||||
<Button onClick={() => updateSpeed(200)}>Slow</Button>
|
||||
<Button onClick={() => updateSpeed(350)}>Normal</Button>
|
||||
<Button onClick={() => updateSpeed(500)}>Fast</Button>
|
||||
<Button onClick={() => updateSpeed(700)}>Very Fast</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{/* Settings */}
|
||||
<IconButton onClick={() => setShowSettings(true)}>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<RSVPSettingsDialog
|
||||
open={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
config={config}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Fixation Guide
|
||||
|
||||
```typescript
|
||||
const FixationGuide: React.FC<{ style: string }> = ({ style }) => {
|
||||
if (style === 'center') {
|
||||
return (
|
||||
<Box sx={{
|
||||
width: 2,
|
||||
height: 60,
|
||||
bgcolor: 'primary.main',
|
||||
opacity: 0.3
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
if (style === 'orpAlgorithm') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: '2px' }}>
|
||||
<Box sx={{ width: 1, height: 40, bgcolor: 'grey.400', opacity: 0.2 }} />
|
||||
<Box sx={{ width: 1, height: 50, bgcolor: 'grey.400', opacity: 0.2 }} />
|
||||
<Box sx={{ width: 2, height: 60, bgcolor: 'primary.main', opacity: 0.4 }} />
|
||||
<Box sx={{ width: 1, height: 50, bgcolor: 'grey.400', opacity: 0.2 }} />
|
||||
<Box sx={{ width: 1, height: 40, bgcolor: 'grey.400', opacity: 0.2 }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Comprehension Quiz
|
||||
|
||||
```typescript
|
||||
interface ComprehensionQuiz {
|
||||
id: string
|
||||
verseReference: string
|
||||
question: string
|
||||
options: string[]
|
||||
correctAnswer: number
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
const ComprehensionQuiz: React.FC<{
|
||||
quiz: ComprehensionQuiz
|
||||
onAnswer: (correct: boolean) => void
|
||||
}> = ({ quiz, onAnswer }) => {
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null)
|
||||
const [showResult, setShowResult] = useState(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const isCorrect = selectedAnswer === quiz.correctAnswer
|
||||
setShowResult(true)
|
||||
setTimeout(() => {
|
||||
onAnswer(isCorrect)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Comprehension Check</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{quiz.verseReference}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||
{quiz.question}
|
||||
</Typography>
|
||||
|
||||
<RadioGroup value={selectedAnswer} onChange={(e) => setSelectedAnswer(Number(e.target.value))}>
|
||||
{quiz.options.map((option, index) => (
|
||||
<FormControlLabel
|
||||
key={index}
|
||||
value={index}
|
||||
control={<Radio />}
|
||||
label={option}
|
||||
disabled={showResult}
|
||||
sx={{
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: showResult
|
||||
? index === quiz.correctAnswer
|
||||
? 'success.light'
|
||||
: index === selectedAnswer
|
||||
? 'error.light'
|
||||
: 'transparent'
|
||||
: 'transparent'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{showResult && quiz.explanation && (
|
||||
<Alert severity={selectedAnswer === quiz.correctAnswer ? 'success' : 'info'} sx={{ mt: 2 }}>
|
||||
{quiz.explanation}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={selectedAnswer === null || showResult}
|
||||
variant="contained"
|
||||
>
|
||||
Submit Answer
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Progress Tracking
|
||||
|
||||
```typescript
|
||||
interface ReadingSession {
|
||||
id: string
|
||||
userId: string
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
wordsRead: number
|
||||
averageWPM: number
|
||||
peakWPM: number
|
||||
comprehensionScore: number // 0-100%
|
||||
book: string
|
||||
chapter: number
|
||||
}
|
||||
|
||||
const ProgressTracker: React.FC = () => {
|
||||
const [sessions, setSessions] = useState<ReadingSession[]>([])
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
loadStats()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Speed Reading Progress
|
||||
</Typography>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard
|
||||
title="Current Speed"
|
||||
value={`${stats?.currentWPM || 0} WPM`}
|
||||
icon={<SpeedIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard
|
||||
title="Improvement"
|
||||
value={`+${stats?.improvement || 0}%`}
|
||||
icon={<TrendingUpIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard
|
||||
title="Total Words"
|
||||
value={formatNumber(stats?.totalWords || 0)}
|
||||
icon={<MenuBookIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard
|
||||
title="Avg Comprehension"
|
||||
value={`${stats?.avgComprehension || 0}%`}
|
||||
icon={<CheckCircleIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Progress Chart */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Reading Speed Over Time
|
||||
</Typography>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={sessions}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="averageWPM" stroke="#8884d8" name="Average WPM" />
|
||||
<Line type="monotone" dataKey="peakWPM" stroke="#82ca9d" name="Peak WPM" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Paper>
|
||||
|
||||
{/* Session History */}
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Recent Sessions
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Passage</TableCell>
|
||||
<TableCell>Words</TableCell>
|
||||
<TableCell>Avg WPM</TableCell>
|
||||
<TableCell>Comprehension</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sessions.map(session => (
|
||||
<TableRow key={session.id}>
|
||||
<TableCell>{formatDate(session.startTime)}</TableCell>
|
||||
<TableCell>{session.book} {session.chapter}</TableCell>
|
||||
<TableCell>{session.wordsRead}</TableCell>
|
||||
<TableCell>{session.averageWPM}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={`${session.comprehensionScore}%`}
|
||||
color={session.comprehensionScore >= 80 ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Training Exercises
|
||||
|
||||
```typescript
|
||||
const SpeedReadingTraining: React.FC = () => {
|
||||
const [currentExercise, setCurrentExercise] = useState(0)
|
||||
|
||||
const exercises = [
|
||||
{
|
||||
name: 'Word Recognition',
|
||||
description: 'Practice recognizing words at increasing speeds',
|
||||
component: <WordRecognitionExercise />
|
||||
},
|
||||
{
|
||||
name: 'Peripheral Vision',
|
||||
description: 'Expand your field of vision',
|
||||
component: <PeripheralVisionExercise />
|
||||
},
|
||||
{
|
||||
name: 'Chunking Practice',
|
||||
description: 'Read multiple words at once',
|
||||
component: <ChunkingExercise />
|
||||
},
|
||||
{
|
||||
name: 'Speed Progression',
|
||||
description: 'Gradually increase reading speed',
|
||||
component: <ProgressionExercise />
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Speed Reading Training
|
||||
</Typography>
|
||||
|
||||
<Stepper activeStep={currentExercise} sx={{ mb: 4 }}>
|
||||
{exercises.map((exercise, index) => (
|
||||
<Step key={exercise.name}>
|
||||
<StepLabel>{exercise.name}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{exercises[currentExercise].component}
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
disabled={currentExercise === 0}
|
||||
onClick={() => setCurrentExercise(prev => prev - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setCurrentExercise(prev => Math.min(prev + 1, exercises.length - 1))}
|
||||
>
|
||||
{currentExercise === exercises.length - 1 ? 'Finish' : 'Next'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
```prisma
|
||||
model SpeedReadingSession {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
startTime DateTime
|
||||
endTime DateTime
|
||||
wordsRead Int
|
||||
averageWPM Int
|
||||
peakWPM Int
|
||||
lowestWPM Int
|
||||
|
||||
book String
|
||||
chapter Int
|
||||
startVerse Int
|
||||
endVerse Int
|
||||
|
||||
comprehensionScore Float? // 0-100
|
||||
quizzesTaken Int @default(0)
|
||||
quizzesCorrect Int @default(0)
|
||||
|
||||
config Json // RSVPConfig snapshot
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model SpeedReadingStats {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
totalSessions Int @default(0)
|
||||
totalWords BigInt @default(0)
|
||||
totalMinutes Int @default(0)
|
||||
|
||||
currentWPM Int @default(200)
|
||||
startingWPM Int @default(200)
|
||||
peakWPM Int @default(200)
|
||||
|
||||
avgComprehension Float @default(0)
|
||||
|
||||
lastSessionAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Timeline
|
||||
|
||||
### Week 1: Core RSVP
|
||||
**Day 1-2:** Foundation
|
||||
- [ ] RSVP display component
|
||||
- [ ] Word timing logic
|
||||
- [ ] Basic controls
|
||||
|
||||
**Day 3-4:** Features
|
||||
- [ ] Fixation point
|
||||
- [ ] Speed adjustment
|
||||
- [ ] Multiple display modes
|
||||
|
||||
**Day 5:** Testing
|
||||
- [ ] Performance optimization
|
||||
- [ ] User testing
|
||||
- [ ] Bug fixes
|
||||
|
||||
### Week 2: Advanced
|
||||
**Day 1-2:** Comprehension
|
||||
- [ ] Quiz system
|
||||
- [ ] Auto-adjustment
|
||||
- [ ] Results tracking
|
||||
|
||||
**Day 3-4:** Analytics
|
||||
- [ ] Progress tracking
|
||||
- [ ] Statistics dashboard
|
||||
- [ ] Training exercises
|
||||
|
||||
**Day 5:** Launch
|
||||
- [ ] Final polish
|
||||
- [ ] Documentation
|
||||
- [ ] Deployment
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Status:** Ready for Implementation
|
||||
795
TAGS_CATEGORIES_SYSTEM_PLAN.md
Normal file
795
TAGS_CATEGORIES_SYSTEM_PLAN.md
Normal file
@@ -0,0 +1,795 @@
|
||||
# Tags & Categories System - Implementation Plan
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement a flexible tagging and categorization system allowing users to organize highlights, notes, bookmarks, and verses by themes, topics, and custom labels for enhanced discovery and thematic study.
|
||||
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🟡 Medium
|
||||
**Estimated Time:** 1-2 weeks (40-80 hours)
|
||||
**Target Completion:** TBD
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals & Objectives
|
||||
|
||||
### Primary Goals
|
||||
1. Create flexible tagging system for all content types
|
||||
2. Provide predefined tag library for common themes
|
||||
3. Enable hierarchical categories (parent/child relationships)
|
||||
4. Support tag-based filtering and discovery
|
||||
5. Visualize tag usage with tag clouds and statistics
|
||||
|
||||
### User Value Proposition
|
||||
- **For students**: Organize study materials by theme
|
||||
- **For scholars**: Track theological concepts across Scripture
|
||||
- **For teachers**: Prepare thematic lessons
|
||||
- **For personal study**: Build custom topical studies
|
||||
- **For research**: Discover patterns and connections
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Specifications
|
||||
|
||||
### 1. Tag Data Model
|
||||
|
||||
```typescript
|
||||
interface Tag {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
slug: string // URL-friendly version
|
||||
color: string
|
||||
icon?: string
|
||||
description?: string
|
||||
|
||||
// Hierarchy
|
||||
parentId: string | null
|
||||
parent?: Tag
|
||||
children?: Tag[]
|
||||
level: number // 0 = root, 1 = child, 2 = grandchild
|
||||
|
||||
// Metadata
|
||||
usageCount: number // Number of items with this tag
|
||||
isSystem: boolean // Predefined vs user-created
|
||||
isPublic: boolean // Shared with community
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// Taggable entities
|
||||
type TaggableType = 'highlight' | 'note' | 'bookmark' | 'verse' | 'chapter' | 'prayer'
|
||||
|
||||
interface TagAssignment {
|
||||
id: string
|
||||
tagId: string
|
||||
tag: Tag
|
||||
entityType: TaggableType
|
||||
entityId: string
|
||||
userId: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// Pre-defined tag categories
|
||||
const TAG_CATEGORIES = {
|
||||
'biblical-themes': {
|
||||
name: 'Biblical Themes',
|
||||
tags: [
|
||||
'salvation', 'faith', 'love', 'hope', 'grace', 'mercy',
|
||||
'judgment', 'redemption', 'covenant', 'kingdom', 'prophecy'
|
||||
]
|
||||
},
|
||||
'character-traits': {
|
||||
name: 'Character Traits',
|
||||
tags: [
|
||||
'courage', 'wisdom', 'patience', 'kindness', 'humility',
|
||||
'faithfulness', 'self-control', 'perseverance', 'integrity'
|
||||
]
|
||||
},
|
||||
'spiritual-disciplines': {
|
||||
name: 'Spiritual Disciplines',
|
||||
tags: [
|
||||
'prayer', 'fasting', 'worship', 'meditation', 'service',
|
||||
'stewardship', 'evangelism', 'fellowship', 'study'
|
||||
]
|
||||
},
|
||||
'life-topics': {
|
||||
name: 'Life Topics',
|
||||
tags: [
|
||||
'marriage', 'parenting', 'work', 'relationships', 'finances',
|
||||
'health', 'anxiety', 'depression', 'grief', 'forgiveness'
|
||||
]
|
||||
},
|
||||
'biblical-people': {
|
||||
name: 'Biblical People',
|
||||
tags: [
|
||||
'abraham', 'moses', 'david', 'jesus', 'paul', 'peter',
|
||||
'mary', 'esther', 'daniel', 'joshua'
|
||||
]
|
||||
},
|
||||
'literary-types': {
|
||||
name: 'Literary Types',
|
||||
tags: [
|
||||
'narrative', 'poetry', 'prophecy', 'parable', 'epistle',
|
||||
'law', 'wisdom', 'apocalyptic', 'gospel', 'proverb'
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Tag Management Interface
|
||||
|
||||
```typescript
|
||||
const TagManager: React.FC = () => {
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'list' | 'tree' | 'cloud'>('tree')
|
||||
const [filterCategory, setFilterCategory] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh' }}>
|
||||
{/* Sidebar */}
|
||||
<Box sx={{ width: 250, borderRight: 1, borderColor: 'divider', p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Tags & Categories
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => createNewTag()}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
New Tag
|
||||
</Button>
|
||||
|
||||
<List>
|
||||
<ListItem button selected={!filterCategory} onClick={() => setFilterCategory(null)}>
|
||||
<ListItemIcon><AllInboxIcon /></ListItemIcon>
|
||||
<ListItemText primary="All Tags" secondary={tags.length} />
|
||||
</ListItem>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{Object.entries(TAG_CATEGORIES).map(([key, category]) => (
|
||||
<ListItem
|
||||
key={key}
|
||||
button
|
||||
selected={filterCategory === key}
|
||||
onClick={() => setFilterCategory(key)}
|
||||
>
|
||||
<ListItemIcon><CategoryIcon /></ListItemIcon>
|
||||
<ListItemText primary={category.name} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Main Content */}
|
||||
<Box sx={{ flex: 1, p: 3 }}>
|
||||
{/* View Mode Selector */}
|
||||
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h5">
|
||||
{filterCategory
|
||||
? TAG_CATEGORIES[filterCategory]?.name
|
||||
: 'All Tags'}
|
||||
</Typography>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setViewMode(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="list">
|
||||
<ViewListIcon />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="tree">
|
||||
<AccountTreeIcon />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="cloud">
|
||||
<CloudIcon />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Display Tags */}
|
||||
{viewMode === 'list' && <TagList tags={tags} onSelect={setSelectedTag} />}
|
||||
{viewMode === 'tree' && <TagTree tags={tags} onSelect={setSelectedTag} />}
|
||||
{viewMode === 'cloud' && <TagCloud tags={tags} onSelect={setSelectedTag} />}
|
||||
</Box>
|
||||
|
||||
{/* Tag Details Panel */}
|
||||
{selectedTag && (
|
||||
<TagDetailsPanel
|
||||
tag={selectedTag}
|
||||
onClose={() => setSelectedTag(null)}
|
||||
onUpdate={updateTag}
|
||||
onDelete={deleteTag}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Tag Input Component (Autocomplete)
|
||||
|
||||
```typescript
|
||||
const TagInput: React.FC<{
|
||||
value: string[]
|
||||
onChange: (tags: string[]) => void
|
||||
entityType?: TaggableType
|
||||
}> = ({ value, onChange, entityType }) => {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<Tag[]>([])
|
||||
|
||||
// Load suggestions as user types
|
||||
const handleInputChange = useDebounce(async (input: string) => {
|
||||
if (input.length < 2) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/tags/search?q=${encodeURIComponent(input)}&type=${entityType || ''}`
|
||||
)
|
||||
const data = await response.json()
|
||||
setSuggestions(data.tags)
|
||||
}, 300)
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
options={suggestions}
|
||||
value={value}
|
||||
onChange={(_, newValue) => onChange(newValue)}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(_, newInputValue) => {
|
||||
setInputValue(newInputValue)
|
||||
handleInputChange(newInputValue)
|
||||
}}
|
||||
getOptionLabel={(option) => typeof option === 'string' ? option : option.name}
|
||||
renderOption={(props, option) => (
|
||||
<Box component="li" {...props}>
|
||||
<Chip
|
||||
label={option.name}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: option.color,
|
||||
color: getContrastColor(option.color)
|
||||
}}
|
||||
icon={option.icon ? <span>{option.icon}</span> : undefined}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ ml: 1, color: 'text.secondary' }}>
|
||||
{option.usageCount} uses
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => {
|
||||
const tag = typeof option === 'string'
|
||||
? { name: option, color: '#1976d2' }
|
||||
: option
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={tag.name}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color: getContrastColor(tag.color)
|
||||
}}
|
||||
{...getTagProps({ index })}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Tags"
|
||||
placeholder="Add tags..."
|
||||
helperText="Type to search or create new tags"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Tag Tree View (Hierarchical)
|
||||
|
||||
```typescript
|
||||
interface TagTreeProps {
|
||||
tags: Tag[]
|
||||
onSelect: (tag: Tag) => void
|
||||
}
|
||||
|
||||
const TagTree: React.FC<TagTreeProps> = ({ tags, onSelect }) => {
|
||||
const [expanded, setExpanded] = useState<string[]>([])
|
||||
|
||||
// Build tree structure
|
||||
const rootTags = tags.filter(t => !t.parentId)
|
||||
const childrenMap = useMemo(() => {
|
||||
const map = new Map<string, Tag[]>()
|
||||
tags.forEach(tag => {
|
||||
if (tag.parentId) {
|
||||
const children = map.get(tag.parentId) || []
|
||||
children.push(tag)
|
||||
map.set(tag.parentId, children)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [tags])
|
||||
|
||||
const renderTagNode = (tag: Tag, level: number = 0) => {
|
||||
const children = childrenMap.get(tag.id) || []
|
||||
const hasChildren = children.length > 0
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
key={tag.id}
|
||||
nodeId={tag.id}
|
||||
label={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
py: 0.5,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => onSelect(tag)}
|
||||
>
|
||||
<Chip
|
||||
label={tag.name}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color: getContrastColor(tag.color)
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{tag.usageCount} uses
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{hasChildren && children.map(child => renderTagNode(child, level + 1))}
|
||||
</TreeItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeView
|
||||
expanded={expanded}
|
||||
onNodeToggle={(_, nodeIds) => setExpanded(nodeIds)}
|
||||
defaultCollapseIcon={<ExpandMoreIcon />}
|
||||
defaultExpandIcon={<ChevronRightIcon />}
|
||||
>
|
||||
{rootTags.map(tag => renderTagNode(tag))}
|
||||
</TreeView>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Tag Cloud Visualization
|
||||
|
||||
```typescript
|
||||
const TagCloud: React.FC<{
|
||||
tags: Tag[]
|
||||
onSelect: (tag: Tag) => void
|
||||
}> = ({ tags, onSelect }) => {
|
||||
// Calculate font sizes based on usage
|
||||
const maxUsage = Math.max(...tags.map(t => t.usageCount), 1)
|
||||
const minUsage = Math.min(...tags.map(t => t.usageCount), 0)
|
||||
|
||||
const calculateSize = (usage: number): number => {
|
||||
const minSize = 12
|
||||
const maxSize = 48
|
||||
const normalized = (usage - minUsage) / (maxUsage - minUsage || 1)
|
||||
return minSize + (normalized * (maxSize - minSize))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: 4
|
||||
}}
|
||||
>
|
||||
{tags.map(tag => (
|
||||
<Chip
|
||||
key={tag.id}
|
||||
label={tag.name}
|
||||
onClick={() => onSelect(tag)}
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color: getContrastColor(tag.color),
|
||||
fontSize: `${calculateSize(tag.usageCount)}px`,
|
||||
height: 'auto',
|
||||
padding: '8px 12px'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Tag-Based Filtering
|
||||
|
||||
```typescript
|
||||
const TagFilter: React.FC<{
|
||||
selectedTags: string[]
|
||||
onChange: (tags: string[]) => void
|
||||
mode: 'any' | 'all' // Match any tag or all tags
|
||||
onModeChange: (mode: 'any' | 'all') => void
|
||||
}> = ({ selectedTags, onChange, mode, onModeChange }) => {
|
||||
const [availableTags, setAvailableTags] = useState<Tag[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadTags()
|
||||
}, [])
|
||||
|
||||
const loadTags = async () => {
|
||||
const response = await fetch('/api/tags')
|
||||
const data = await response.json()
|
||||
setAvailableTags(data.tags)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={availableTags}
|
||||
value={availableTags.filter(t => selectedTags.includes(t.id))}
|
||||
onChange={(_, newValue) => onChange(newValue.map(t => t.id))}
|
||||
getOptionLabel={(option) => option.name}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Filter by tags" size="small" />
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
label={option.name}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: option.color,
|
||||
color: getContrastColor(option.color)
|
||||
}}
|
||||
{...getTagProps({ index })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={mode}
|
||||
exclusive
|
||||
onChange={(_, value) => value && onModeChange(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="any">Any</ToggleButton>
|
||||
<ToggleButton value="all">All</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{selectedTags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{selectedTags.map(tagId => {
|
||||
const tag = availableTags.find(t => t.id === tagId)
|
||||
if (!tag) return null
|
||||
|
||||
return (
|
||||
<Chip
|
||||
key={tagId}
|
||||
label={tag.name}
|
||||
onDelete={() => onChange(selectedTags.filter(id => id !== tagId))}
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color: getContrastColor(tag.color)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Button size="small" onClick={() => onChange([])}>
|
||||
Clear All
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Tag Statistics & Analytics
|
||||
|
||||
```typescript
|
||||
const TagStatistics: React.FC<{ tag: Tag }> = ({ tag }) => {
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [tag.id])
|
||||
|
||||
const loadStats = async () => {
|
||||
const response = await fetch(`/api/tags/${tag.id}/stats`)
|
||||
const data = await response.json()
|
||||
setStats(data)
|
||||
}
|
||||
|
||||
if (!stats) return <CircularProgress />
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Statistics for "{tag.name}"
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4">{stats.totalUses}</Typography>
|
||||
<Typography variant="caption">Total Uses</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4">{stats.highlights}</Typography>
|
||||
<Typography variant="caption">Highlights</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4">{stats.notes}</Typography>
|
||||
<Typography variant="caption">Notes</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Paper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4">{stats.verses}</Typography>
|
||||
<Typography variant="caption">Verses</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Most Tagged Books */}
|
||||
<Typography variant="subtitle1" sx={{ mt: 3, mb: 1 }}>
|
||||
Most Tagged Books
|
||||
</Typography>
|
||||
<List dense>
|
||||
{stats.topBooks?.map((book: any) => (
|
||||
<ListItem key={book.name}>
|
||||
<ListItemText
|
||||
primary={book.name}
|
||||
secondary={`${book.count} items`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* Usage Over Time */}
|
||||
<Typography variant="subtitle1" sx={{ mt: 3, mb: 1 }}>
|
||||
Usage Over Time
|
||||
</Typography>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={stats.usageOverTime}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="count" stroke={tag.color} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Bulk Tag Operations
|
||||
|
||||
```typescript
|
||||
const BulkTagEditor: React.FC<{
|
||||
selectedItems: string[]
|
||||
entityType: TaggableType
|
||||
onComplete: () => void
|
||||
}> = ({ selectedItems, entityType, onComplete }) => {
|
||||
const [mode, setMode] = useState<'add' | 'remove' | 'replace'>('add')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
|
||||
const handleApply = async () => {
|
||||
await fetch('/api/tags/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: selectedItems,
|
||||
entityType,
|
||||
mode,
|
||||
tags
|
||||
})
|
||||
})
|
||||
|
||||
onComplete()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onComplete} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Edit Tags for {selectedItems.length} items
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Action</InputLabel>
|
||||
<Select value={mode} onChange={(e) => setMode(e.target.value as any)}>
|
||||
<MenuItem value="add">Add Tags</MenuItem>
|
||||
<MenuItem value="remove">Remove Tags</MenuItem>
|
||||
<MenuItem value="replace">Replace All Tags</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TagInput value={tags} onChange={setTags} entityType={entityType} />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onComplete}>Cancel</Button>
|
||||
<Button onClick={handleApply} variant="contained">
|
||||
Apply to {selectedItems.length} items
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
```prisma
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
name String
|
||||
slug String
|
||||
color String @default("#1976d2")
|
||||
icon String?
|
||||
description String?
|
||||
|
||||
parentId String?
|
||||
parent Tag? @relation("TagHierarchy", fields: [parentId], references: [id])
|
||||
children Tag[] @relation("TagHierarchy")
|
||||
level Int @default(0)
|
||||
|
||||
usageCount Int @default(0)
|
||||
isSystem Boolean @default(false)
|
||||
isPublic Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
assignments TagAssignment[]
|
||||
|
||||
@@unique([userId, slug])
|
||||
@@index([userId, name])
|
||||
@@index([isSystem, isPublic])
|
||||
}
|
||||
|
||||
model TagAssignment {
|
||||
id String @id @default(cuid())
|
||||
tagId String
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
entityType String // 'highlight', 'note', 'bookmark', 'verse'
|
||||
entityId String
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([tagId, entityType, entityId])
|
||||
@@index([entityType, entityId])
|
||||
@@index([userId, tagId])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
```typescript
|
||||
// Get all tags
|
||||
GET /api/tags
|
||||
Query: ?userId=xxx&search=xxx&category=xxx
|
||||
Response: { tags: Tag[] }
|
||||
|
||||
// Create tag
|
||||
POST /api/tags
|
||||
Body: { name, color, parentId?, description? }
|
||||
Response: { tag: Tag }
|
||||
|
||||
// Update tag
|
||||
PUT /api/tags/:id
|
||||
Body: Partial<Tag>
|
||||
|
||||
// Delete tag
|
||||
DELETE /api/tags/:id
|
||||
|
||||
// Search tags
|
||||
GET /api/tags/search?q=keyword
|
||||
Response: { tags: Tag[] }
|
||||
|
||||
// Get tag statistics
|
||||
GET /api/tags/:id/stats
|
||||
Response: { totalUses, highlights, notes, verses, topBooks, usageOverTime }
|
||||
|
||||
// Assign tags to entity
|
||||
POST /api/tags/assign
|
||||
Body: { entityType, entityId, tagIds: string[] }
|
||||
|
||||
// Bulk tag operations
|
||||
POST /api/tags/bulk
|
||||
Body: { items: string[], entityType, mode: 'add'|'remove'|'replace', tags: string[] }
|
||||
|
||||
// Get entities by tags
|
||||
GET /api/tags/filter
|
||||
Query: ?tagIds[]=xxx&mode=any|all&entityType=xxx
|
||||
Response: { items: any[] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Timeline
|
||||
|
||||
### Week 1
|
||||
**Day 1-2:** Foundation
|
||||
- [ ] Database schema
|
||||
- [ ] API endpoints
|
||||
- [ ] Tag CRUD operations
|
||||
|
||||
**Day 3-4:** UI Components
|
||||
- [ ] Tag input with autocomplete
|
||||
- [ ] Tag manager interface
|
||||
- [ ] Tree and cloud views
|
||||
|
||||
**Day 5:** Integration
|
||||
- [ ] Add tags to highlights
|
||||
- [ ] Add tags to notes
|
||||
- [ ] Tag-based filtering
|
||||
|
||||
### Week 2 (Optional)
|
||||
**Day 1-2:** Advanced Features
|
||||
- [ ] Hierarchical tags
|
||||
- [ ] Tag statistics
|
||||
- [ ] Bulk operations
|
||||
|
||||
**Day 3-4:** Polish
|
||||
- [ ] Performance optimization
|
||||
- [ ] Mobile UI
|
||||
- [ ] Testing
|
||||
|
||||
**Day 5:** Launch
|
||||
- [ ] Documentation
|
||||
- [ ] Deploy
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-13
|
||||
**Status:** Ready for Implementation
|
||||
1119
TEXT_TO_SPEECH_IMPLEMENTATION_PLAN.md
Normal file
1119
TEXT_TO_SPEECH_IMPLEMENTATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
40
__tests__/components/verse-details-panel.test.tsx
Normal file
40
__tests__/components/verse-details-panel.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { VersDetailsPanel } from '@/components/bible/verse-details-panel'
|
||||
|
||||
const mockVerse = {
|
||||
id: 'v1',
|
||||
verseNum: 1,
|
||||
text: 'In the beginning...',
|
||||
bookId: 1,
|
||||
chapter: 1
|
||||
}
|
||||
|
||||
describe('VersDetailsPanel', () => {
|
||||
it('renders when open with verse data', () => {
|
||||
render(
|
||||
<VersDetailsPanel
|
||||
verse={mockVerse}
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
isBookmarked={false}
|
||||
onToggleBookmark={() => {}}
|
||||
onAddNote={() => {}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/In the beginning/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when closed', () => {
|
||||
const { container } = render(
|
||||
<VersDetailsPanel
|
||||
verse={mockVerse}
|
||||
isOpen={false}
|
||||
onClose={() => {}}
|
||||
isBookmarked={false}
|
||||
onToggleBookmark={() => {}}
|
||||
onAddNote={() => {}}
|
||||
/>
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
36
__tests__/lib/bible-search.test.ts
Normal file
36
__tests__/lib/bible-search.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { searchBooks, parseReference } from '@/lib/bible-search'
|
||||
|
||||
describe('searchBooks', () => {
|
||||
it('returns results for exact book prefix', () => {
|
||||
const results = searchBooks('Genesis')
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0].bookName).toBe('Genesis')
|
||||
})
|
||||
|
||||
it('parses "Book Chapter" format', () => {
|
||||
const results = searchBooks('Genesis 5')
|
||||
expect(results[0].chapter).toBe(5)
|
||||
})
|
||||
|
||||
it('works with abbreviations', () => {
|
||||
const results = searchBooks('Gen 1')
|
||||
expect(results[0].bookName).toBe('Genesis')
|
||||
})
|
||||
|
||||
it('returns empty array for empty query', () => {
|
||||
expect(searchBooks('').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseReference', () => {
|
||||
it('parses full book name with chapter', () => {
|
||||
const result = parseReference('Genesis 3')
|
||||
expect(result?.bookId).toBe(1)
|
||||
expect(result?.chapter).toBe(3)
|
||||
})
|
||||
|
||||
it('defaults to chapter 1', () => {
|
||||
const result = parseReference('Genesis')
|
||||
expect(result?.chapter).toBe(1)
|
||||
})
|
||||
})
|
||||
172
__tests__/lib/cache-manager.test.ts
Normal file
172
__tests__/lib/cache-manager.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { initDatabase, cacheChapter, getCachedChapter, clearExpiredCache } from '@/lib/cache-manager'
|
||||
import { BibleChapter } from '@/types'
|
||||
|
||||
// Mock IndexedDB for testing
|
||||
const mockIndexedDB = (() => {
|
||||
let stores: Record<string, Record<string, any>> = {}
|
||||
let dbVersion = 0
|
||||
|
||||
return {
|
||||
open: (name: string, version: number) => {
|
||||
const request: any = {
|
||||
result: null,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
onupgradeneeded: null,
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (version > dbVersion) {
|
||||
dbVersion = version
|
||||
const upgradeEvent: any = {
|
||||
target: {
|
||||
result: {
|
||||
objectStoreNames: {
|
||||
contains: (name: string) => !!stores[name]
|
||||
},
|
||||
createObjectStore: (storeName: string, options: any) => {
|
||||
stores[storeName] = {}
|
||||
return {
|
||||
createIndex: () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
request.onupgradeneeded?.(upgradeEvent)
|
||||
}
|
||||
|
||||
request.result = {
|
||||
transaction: (storeNames: string[], mode: string) => {
|
||||
const storeName = storeNames[0]
|
||||
return {
|
||||
objectStore: (name: string) => {
|
||||
if (!stores[name]) stores[name] = {}
|
||||
return {
|
||||
get: (key: string) => {
|
||||
const req: any = {
|
||||
result: stores[name][key],
|
||||
onsuccess: null,
|
||||
onerror: null
|
||||
}
|
||||
setTimeout(() => req.onsuccess?.(), 0)
|
||||
return req
|
||||
},
|
||||
put: (value: any) => {
|
||||
const key = value.chapterId
|
||||
stores[name][key] = value
|
||||
const req: any = {
|
||||
onsuccess: null,
|
||||
onerror: null
|
||||
}
|
||||
setTimeout(() => req.onsuccess?.(), 0)
|
||||
return req
|
||||
},
|
||||
count: () => {
|
||||
const req: any = {
|
||||
result: Object.keys(stores[name]).length,
|
||||
onsuccess: null
|
||||
}
|
||||
setTimeout(() => req.onsuccess?.(), 0)
|
||||
return req
|
||||
},
|
||||
index: (indexName: string) => {
|
||||
return {
|
||||
openCursor: (range?: any) => {
|
||||
const req: any = {
|
||||
result: null,
|
||||
onsuccess: null
|
||||
}
|
||||
setTimeout(() => req.onsuccess?.({ target: req }), 0)
|
||||
return req
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
request.onsuccess?.()
|
||||
}, 0)
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
// Setup mock for tests
|
||||
beforeAll(() => {
|
||||
;(global as any).indexedDB = mockIndexedDB
|
||||
})
|
||||
|
||||
describe('cache-manager', () => {
|
||||
const mockChapter: BibleChapter = {
|
||||
id: '1-1',
|
||||
bookId: 1,
|
||||
bookName: 'Genesis',
|
||||
chapter: 1,
|
||||
verses: [
|
||||
{
|
||||
id: 'v1',
|
||||
chapterId: '1-1',
|
||||
verseNum: 1,
|
||||
text: 'In the beginning God created the heaven and the earth.',
|
||||
version: 'KJV',
|
||||
chapter: {
|
||||
chapterNum: 1,
|
||||
book: {
|
||||
name: 'Genesis'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('initDatabase', () => {
|
||||
it('initializes the database successfully', async () => {
|
||||
const db = await initDatabase()
|
||||
expect(db).toBeDefined()
|
||||
expect(db.transaction).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cacheChapter', () => {
|
||||
it('caches a chapter successfully', async () => {
|
||||
await cacheChapter(mockChapter)
|
||||
// If no error thrown, test passes
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('creates cache entry with expiration', async () => {
|
||||
await cacheChapter(mockChapter)
|
||||
const cached = await getCachedChapter('1-1')
|
||||
expect(cached).toBeDefined()
|
||||
expect(cached?.id).toBe('1-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCachedChapter', () => {
|
||||
it('returns cached chapter if not expired', async () => {
|
||||
await cacheChapter(mockChapter)
|
||||
const result = await getCachedChapter('1-1')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.bookName).toBe('Genesis')
|
||||
expect(result?.chapter).toBe(1)
|
||||
})
|
||||
|
||||
it('returns null for non-existent chapter', async () => {
|
||||
const result = await getCachedChapter('999-999')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearExpiredCache', () => {
|
||||
it('runs without error', async () => {
|
||||
await clearExpiredCache()
|
||||
// If no error thrown, test passes
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
16
__tests__/lib/reading-preferences.test.ts
Normal file
16
__tests__/lib/reading-preferences.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getCSSVariables, getPreset } from '@/lib/reading-preferences'
|
||||
|
||||
describe('reading-preferences', () => {
|
||||
it('returns default preset', () => {
|
||||
const preset = getPreset('default')
|
||||
expect(preset.fontFamily).toBe('georgia')
|
||||
expect(preset.fontSize).toBe(18)
|
||||
})
|
||||
|
||||
it('generates CSS variables correctly', () => {
|
||||
const preset = getPreset('dyslexia')
|
||||
const vars = getCSSVariables(preset)
|
||||
expect(vars['--font-size']).toBe('18px')
|
||||
expect(vars['--letter-spacing']).toBe('0.08em')
|
||||
})
|
||||
})
|
||||
@@ -1,66 +1,10 @@
|
||||
import { Suspense } from 'react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import BibleReader from './reader'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { BibleReaderApp } from '@/components/bible/bible-reader-app'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{
|
||||
version?: string
|
||||
book?: string
|
||||
chapter?: string
|
||||
verse?: string
|
||||
}>
|
||||
params: Promise<{
|
||||
locale: string
|
||||
}>
|
||||
export const metadata = {
|
||||
title: 'Read Bible',
|
||||
description: 'Modern Bible reader with offline support'
|
||||
}
|
||||
|
||||
// Helper function to convert UUIDs to SEO-friendly slugs
|
||||
async function convertToSeoUrl(versionId: string, bookId: string, chapter: string, locale: string) {
|
||||
try {
|
||||
const version = await prisma.bibleVersion.findUnique({
|
||||
where: { id: versionId }
|
||||
})
|
||||
|
||||
const book = await prisma.bibleBook.findUnique({
|
||||
where: { id: bookId }
|
||||
})
|
||||
|
||||
if (version && book) {
|
||||
const versionSlug = version.abbreviation.toLowerCase()
|
||||
const bookSlug = book.bookKey.toLowerCase()
|
||||
return `/${locale}/bible/${versionSlug}/${bookSlug}/${chapter}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting to SEO URL:', error)
|
||||
}
|
||||
return null
|
||||
export default function BiblePage() {
|
||||
return <BibleReaderApp />
|
||||
}
|
||||
|
||||
export default async function BiblePage({ searchParams, params }: PageProps) {
|
||||
const { version, book, chapter } = await searchParams
|
||||
const { locale } = await params
|
||||
|
||||
// If we have the old URL format with UUIDs, redirect to SEO-friendly URL
|
||||
if (version && book && chapter) {
|
||||
const seoUrl = await convertToSeoUrl(version, book, chapter, locale)
|
||||
if (seoUrl) {
|
||||
redirect(seoUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '200px'
|
||||
}}>
|
||||
Loading Bible reader...
|
||||
</div>
|
||||
}>
|
||||
<BibleReader />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BibleReader } from '@/components/bible/reader'
|
||||
import { ChatInterface } from '@/components/chat/chat-interface'
|
||||
import { PrayerWall } from '@/components/prayer/prayer-wall'
|
||||
// DISABLED: Prayer Wall Feature
|
||||
// import { PrayerWall } from '@/components/prayer/prayer-wall'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState('bible')
|
||||
@@ -41,8 +42,9 @@ export default function Dashboard() {
|
||||
return <BibleReader />
|
||||
case 'chat':
|
||||
return <ChatInterface />
|
||||
case 'prayers':
|
||||
return <PrayerWall />
|
||||
// DISABLED: Prayer Wall Feature
|
||||
// case 'prayers':
|
||||
// return <PrayerWall />
|
||||
case 'search':
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
@@ -76,7 +78,8 @@ export default function Dashboard() {
|
||||
const tabs = [
|
||||
{ id: 'bible', label: 'Citește Biblia' },
|
||||
{ id: 'chat', label: 'Chat AI' },
|
||||
{ id: 'prayers', label: 'Rugăciuni' },
|
||||
// DISABLED: Prayer Wall Feature
|
||||
// { id: 'prayers', label: 'Rugăciuni' },
|
||||
{ id: 'search', label: 'Căutare' },
|
||||
]
|
||||
|
||||
|
||||
@@ -127,13 +127,14 @@ export default function Home() {
|
||||
path: '/__open-chat__',
|
||||
color: theme.palette.secondary.main,
|
||||
},
|
||||
{
|
||||
title: t('features.prayers.title'),
|
||||
description: t('features.prayers.description'),
|
||||
icon: <Prayer sx={{ fontSize: 40, color: 'success.main' }} />,
|
||||
path: '/prayers',
|
||||
color: theme.palette.success.main,
|
||||
},
|
||||
// DISABLED: Prayer Wall Feature
|
||||
// {
|
||||
// title: t('features.prayers.title'),
|
||||
// description: t('features.prayers.description'),
|
||||
// icon: <Prayer sx={{ fontSize: 40, color: 'success.main' }} />,
|
||||
// path: '/prayers',
|
||||
// color: theme.palette.success.main,
|
||||
// },
|
||||
{
|
||||
title: t('features.search.title'),
|
||||
description: t('features.search.description'),
|
||||
@@ -372,8 +373,8 @@ export default function Home() {
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Community Prayer Wall */}
|
||||
<Paper sx={{ bgcolor: 'background.paper', py: 6, mb: 8 }}>
|
||||
{/* DISABLED: Community Prayer Wall */}
|
||||
{/* <Paper sx={{ bgcolor: 'background.paper', py: 6, mb: 8 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="h3" component="h2" textAlign="center" sx={{ mb: 6 }}>
|
||||
{t('prayerWall.title')}
|
||||
@@ -415,7 +416,7 @@ export default function Home() {
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</Paper>
|
||||
</Paper> */}
|
||||
|
||||
{/* Features Section */}
|
||||
<Container maxWidth="lg" sx={{ mb: 8 }}>
|
||||
|
||||
@@ -1,807 +1,10 @@
|
||||
'use client'
|
||||
import {
|
||||
Container,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Paper,
|
||||
Avatar,
|
||||
Chip,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
CircularProgress,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Tabs,
|
||||
Tab,
|
||||
FormControlLabel,
|
||||
FormControl,
|
||||
Select,
|
||||
Checkbox,
|
||||
SelectChangeEvent,
|
||||
Switch,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Favorite,
|
||||
Add,
|
||||
Close,
|
||||
Person,
|
||||
AccessTime,
|
||||
FavoriteBorder,
|
||||
Share,
|
||||
MoreVert,
|
||||
AutoAwesome,
|
||||
Edit,
|
||||
Login,
|
||||
ExpandMore,
|
||||
} from '@mui/icons-material'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslations, useLocale, useFormatter } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/use-auth'
|
||||
import { AuthModal } from '@/components/auth/auth-modal'
|
||||
|
||||
interface PrayerRequest {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
author: string
|
||||
timestamp: Date
|
||||
prayerCount: number
|
||||
isPrayedFor: boolean
|
||||
isPublic: boolean
|
||||
language: string
|
||||
isOwner: boolean
|
||||
}
|
||||
// DISABLED: Prayer Wall Feature
|
||||
|
||||
export default function PrayersPage() {
|
||||
const theme = useTheme()
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('pages.prayers')
|
||||
const tc = useTranslations('common')
|
||||
const f = useFormatter()
|
||||
const { user } = useAuth()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
const [prayers, setPrayers] = useState<PrayerRequest[]>([])
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
const [tabValue, setTabValue] = useState(0) // 0 = Write, 1 = AI Generate
|
||||
const [newPrayer, setNewPrayer] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category: 'personal',
|
||||
isPublic: false,
|
||||
})
|
||||
const [aiPrompt, setAiPrompt] = useState('')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewMode, setViewMode] = useState<'private' | 'public'>(user ? 'private' : 'public')
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string>(locale)
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false)
|
||||
|
||||
const languageOptions = useMemo(() => ([
|
||||
{ value: 'en', label: t('languageFilter.options.en') },
|
||||
{ value: 'ro', label: t('languageFilter.options.ro') },
|
||||
{ value: 'es', label: t('languageFilter.options.es') },
|
||||
{ value: 'it', label: t('languageFilter.options.it') }
|
||||
]), [t])
|
||||
const languageLabelMap = useMemo(() => (
|
||||
languageOptions.reduce((acc, option) => {
|
||||
acc[option.value] = option.label
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
), [languageOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setViewMode(prev => (prev === 'private' ? prev : 'private'))
|
||||
} else {
|
||||
setViewMode('public')
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'public') {
|
||||
setSelectedLanguage(locale)
|
||||
}
|
||||
}, [locale, viewMode])
|
||||
|
||||
const categories = [
|
||||
{ value: 'personal', label: t('categories.personal'), color: 'primary' },
|
||||
{ value: 'family', label: t('categories.family'), color: 'secondary' },
|
||||
{ value: 'health', label: t('categories.health'), color: 'error' },
|
||||
{ value: 'work', label: t('categories.work'), color: 'warning' },
|
||||
{ value: 'ministry', label: t('categories.ministry'), color: 'success' },
|
||||
{ value: 'world', label: t('categories.world'), color: 'info' },
|
||||
]
|
||||
|
||||
// Fetch prayers from API
|
||||
const fetchPrayers = async () => {
|
||||
if (viewMode === 'private' && !user) {
|
||||
setPrayers([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (selectedCategory !== 'all') {
|
||||
params.append('category', selectedCategory)
|
||||
}
|
||||
params.append('limit', '50')
|
||||
params.append('visibility', viewMode)
|
||||
|
||||
if (viewMode === 'public') {
|
||||
params.append('languages', selectedLanguage)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/prayers?${params.toString()}`, {
|
||||
headers
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setPrayers(data.prayers.map((prayer: any) => ({
|
||||
...prayer,
|
||||
timestamp: new Date(prayer.timestamp)
|
||||
})))
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
setPrayers([])
|
||||
}
|
||||
console.error('Failed to fetch prayers')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching prayers:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrayers()
|
||||
}, [selectedCategory, user, viewMode, selectedLanguage])
|
||||
|
||||
const handleGenerateAIPrayer = async () => {
|
||||
if (!aiPrompt.trim()) return
|
||||
if (!user) return
|
||||
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const response = await fetch('/api/prayers/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: aiPrompt,
|
||||
category: newPrayer.category,
|
||||
locale
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setNewPrayer({
|
||||
title: data.title || '',
|
||||
description: data.prayer || '',
|
||||
category: newPrayer.category,
|
||||
isPublic: newPrayer.isPublic
|
||||
})
|
||||
setTabValue(0) // Switch to write tab to review generated prayer
|
||||
} else {
|
||||
console.error('Failed to generate prayer')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating prayer:', error)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLanguageChange = (event: SelectChangeEvent<string>) => {
|
||||
const value = event.target.value
|
||||
setSelectedLanguage(value)
|
||||
}
|
||||
|
||||
const handleSubmitPrayer = async () => {
|
||||
if (!newPrayer.title.trim() || !newPrayer.description.trim()) return
|
||||
if (!user) return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken')
|
||||
const response = await fetch('/api/prayers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: newPrayer.title,
|
||||
description: newPrayer.description,
|
||||
category: newPrayer.category,
|
||||
isAnonymous: false,
|
||||
isPublic: newPrayer.isPublic,
|
||||
language: locale
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchPrayers()
|
||||
setNewPrayer({ title: '', description: '', category: 'personal', isPublic: false })
|
||||
setAiPrompt('')
|
||||
setTabValue(0)
|
||||
setOpenDialog(false)
|
||||
} else {
|
||||
console.error('Failed to submit prayer')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting prayer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
if (!user) {
|
||||
setAuthModalOpen(true)
|
||||
return
|
||||
}
|
||||
setOpenDialog(true)
|
||||
}
|
||||
|
||||
const handleAuthSuccess = () => {
|
||||
setAuthModalOpen(false)
|
||||
// After successful auth, open the add prayer dialog
|
||||
setOpenDialog(true)
|
||||
}
|
||||
|
||||
const handlePrayFor = async (prayerId: string) => {
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
const authToken = localStorage.getItem('authToken')
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/prayers/${prayerId}/pray`, {
|
||||
method: 'POST',
|
||||
headers
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setPrayers(prayers.map(prayer =>
|
||||
prayer.id === prayerId
|
||||
? { ...prayer, prayerCount: data.prayerCount || prayer.prayerCount + 1, isPrayedFor: true }
|
||||
: prayer
|
||||
))
|
||||
} else {
|
||||
console.error('Failed to update prayer count')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating prayer count:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryInfo = (category: string) => {
|
||||
return categories.find(cat => cat.value === category) || categories[0]
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp: Date) => {
|
||||
const currentTime = new Date()
|
||||
|
||||
try {
|
||||
// Use the correct API: relativeTime(date, now)
|
||||
return f.relativeTime(timestamp, currentTime)
|
||||
} catch (e) {
|
||||
// Fallback to simple formatting if relativeTime fails
|
||||
const diff = currentTime.getTime() - timestamp.getTime()
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return locale === 'ro' ? `acum ${days} ${days === 1 ? 'zi' : 'zile'}` : `${days} ${days === 1 ? 'day' : 'days'} ago`
|
||||
if (hours > 0) return locale === 'ro' ? `acum ${hours} ${hours === 1 ? 'oră' : 'ore'}` : `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`
|
||||
if (minutes > 0) return locale === 'ro' ? `acum ${minutes} ${minutes === 1 ? 'minut' : 'minute'}` : `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`
|
||||
return locale === 'ro' ? 'acum' : 'just now'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
<Favorite sx={{ fontSize: 40, mr: 2, verticalAlign: 'middle', color: 'error.main' }} />
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 4, flexDirection: { xs: 'column', md: 'row' } }}>
|
||||
{/* Categories Filter */}
|
||||
<Box sx={{ width: { xs: '100%', md: '25%' }, flexShrink: 0 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{/* Add Prayer Button */}
|
||||
{user ? (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Add />}
|
||||
onClick={handleOpenDialog}
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
{t('dialog.title')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Add />}
|
||||
onClick={handleOpenDialog}
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
{t('addPrayer')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Categories Accordion */}
|
||||
<Accordion defaultExpanded={!isMobile}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMore />}
|
||||
aria-controls="categories-content"
|
||||
id="categories-header"
|
||||
>
|
||||
<Typography variant="h6">
|
||||
{t('categories.title')}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Chip
|
||||
label={t('categories.all')}
|
||||
color="default"
|
||||
variant={selectedCategory === 'all' ? 'filled' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => setSelectedCategory('all')}
|
||||
sx={{ justifyContent: 'flex-start', cursor: 'pointer' }}
|
||||
/>
|
||||
{categories.map((category) => (
|
||||
<Chip
|
||||
key={category.value}
|
||||
label={category.label}
|
||||
color={category.color as any}
|
||||
variant={selectedCategory === category.value ? 'filled' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
sx={{ justifyContent: 'flex-start', cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Language Filter Accordion */}
|
||||
{viewMode === 'public' && (
|
||||
<Accordion defaultExpanded={!isMobile} sx={{ mt: 2 }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMore />}
|
||||
aria-controls="language-content"
|
||||
id="language-header"
|
||||
>
|
||||
<Typography variant="h6">
|
||||
{t('languageFilter.title')}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={selectedLanguage}
|
||||
onChange={handleLanguageChange}
|
||||
>
|
||||
{languageOptions.map(option => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{t('languageFilter.helper')}
|
||||
</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Stats Accordion */}
|
||||
<Accordion defaultExpanded={!isMobile} sx={{ mt: 2 }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMore />}
|
||||
aria-controls="stats-content"
|
||||
id="stats-header"
|
||||
>
|
||||
<Typography variant="h6">
|
||||
{t('stats.title')}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• {t('stats.activeRequests', { count: prayers.length })}<br />
|
||||
• {t('stats.totalPrayers', { count: prayers.reduce((sum, p) => sum + p.prayerCount, 0) })}<br />
|
||||
• {t('stats.youPrayed', { count: prayers.filter(p => p.isPrayedFor).length })}
|
||||
</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Prayer Requests */}
|
||||
<Box sx={{ flex: 1, width: { xs: '100%', md: '75%' } }}>
|
||||
{user && (
|
||||
<Tabs
|
||||
value={viewMode}
|
||||
onChange={(_, newValue) => setViewMode(newValue as 'private' | 'public')}
|
||||
sx={{ mb: 3 }}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab value="private" label={t('viewModes.private')} />
|
||||
<Tab value="public" label={t('viewModes.public')} />
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{viewMode === 'private' && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{t('alerts.privateInfo')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{viewMode === 'public' && !user && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{t('alerts.publicInfo')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Skeleton variant="text" width="60%" height={32} />
|
||||
<Skeleton variant="rounded" width={80} height={24} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Skeleton variant="circular" width={24} height={24} />
|
||||
<Skeleton variant="text" width="30%" height={20} />
|
||||
</Box>
|
||||
<Skeleton variant="text" width="100%" height={24} />
|
||||
<Skeleton variant="text" width="90%" height={24} />
|
||||
<Skeleton variant="text" width="95%" height={24} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Skeleton variant="rounded" width={100} height={32} />
|
||||
<Skeleton variant="rounded" width={100} height={32} />
|
||||
</Box>
|
||||
<Skeleton variant="text" width="20%" height={20} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
{prayers.length === 0 ? (
|
||||
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{viewMode === 'private' ? t('empty.private') : t('empty.public')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : prayers.map((prayer) => {
|
||||
const categoryInfo = getCategoryInfo(prayer.category)
|
||||
const authorName = prayer.isOwner ? (locale === 'en' ? 'You' : 'Tu') : prayer.author
|
||||
const languageLabel = languageLabelMap[prayer.language] || prayer.language.toUpperCase()
|
||||
return (
|
||||
<Card key={prayer.id} sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" component="h3">
|
||||
{prayer.title}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1, mt: 1 }}>
|
||||
<Chip
|
||||
label={categoryInfo.label}
|
||||
color={categoryInfo.color as any}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={prayer.isPublic ? t('chips.public') : t('chips.private')}
|
||||
size="small"
|
||||
color={prayer.isPublic ? 'success' : 'default'}
|
||||
variant={prayer.isPublic ? 'filled' : 'outlined'}
|
||||
/>
|
||||
<Chip
|
||||
label={languageLabel}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Avatar sx={{ width: 24, height: 24, bgcolor: 'primary.main' }}>
|
||||
<Person sx={{ fontSize: 16 }} />
|
||||
</Avatar>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{authorName}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<AccessTime sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatTimestamp(prayer.timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{prayer.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<IconButton size="small">
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant={prayer.isPrayedFor ? "contained" : "outlined"}
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon={prayer.isPrayedFor ? <Favorite /> : <FavoriteBorder />}
|
||||
onClick={() => handlePrayFor(prayer.id)}
|
||||
disabled={prayer.isPrayedFor}
|
||||
>
|
||||
{prayer.isPrayedFor ? t('buttons.prayed') : t('buttons.pray')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Share />}
|
||||
disabled={!prayer.isPublic}
|
||||
>
|
||||
{t('buttons.share')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('stats.totalPrayers', { count: prayer.prayerCount })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Add Prayer Dialog */}
|
||||
<Dialog
|
||||
open={openDialog}
|
||||
onClose={() => setOpenDialog(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{t('dialog.title')}
|
||||
<IconButton onClick={() => setOpenDialog(false)} size="small">
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
{/* Tabs for Write vs AI Generate */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={(e, newValue) => setTabValue(newValue)} centered>
|
||||
<Tab
|
||||
icon={<Edit />}
|
||||
label={locale === 'en' ? 'Write Prayer' : 'Scrie rugăciune'}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
icon={<AutoAwesome />}
|
||||
label={locale === 'en' ? 'AI Generate' : 'Generează cu AI'}
|
||||
iconPosition="start"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<DialogContent sx={{ minHeight: 400 }}>
|
||||
{/* Write Prayer Tab */}
|
||||
{tabValue === 0 && (
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('dialog.titleLabel')}
|
||||
value={newPrayer.title}
|
||||
onChange={(e) => setNewPrayer({ ...newPrayer, title: e.target.value })}
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('dialog.categoryLabel')}
|
||||
select
|
||||
value={newPrayer.category}
|
||||
onChange={(e) => setNewPrayer({ ...newPrayer, category: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{categories.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('dialog.descriptionLabel')}
|
||||
multiline
|
||||
rows={6}
|
||||
value={newPrayer.description}
|
||||
onChange={(e) => setNewPrayer({ ...newPrayer, description: e.target.value })}
|
||||
placeholder={t('dialog.placeholder')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* AI Generate Prayer Tab */}
|
||||
{tabValue === 1 && (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{locale === 'en'
|
||||
? 'Describe what you\'d like to pray about, and AI will help you create a meaningful prayer.'
|
||||
: 'Descrie pentru ce ai vrea să te rogi, iar AI-ul te va ajuta să creezi o rugăciune semnificativă.'
|
||||
}
|
||||
</Alert>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('dialog.categoryLabel')}
|
||||
select
|
||||
value={newPrayer.category}
|
||||
onChange={(e) => setNewPrayer({ ...newPrayer, category: e.target.value })}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{categories.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={locale === 'en' ? 'What would you like to pray about?' : 'Pentru ce ai vrea să te rogi?'}
|
||||
multiline
|
||||
rows={4}
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
placeholder={locale === 'en'
|
||||
? 'e.g., "Help me find peace during a difficult time at work" or "Guidance for my family\'s health struggles"'
|
||||
: 'ex. "Ajută-mă să găsesc pace într-o perioadă dificilă la muncă" sau "Îndrumarea pentru problemele de sănătate ale familiei mele"'
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleGenerateAIPrayer}
|
||||
disabled={!aiPrompt.trim() || isGenerating}
|
||||
startIcon={isGenerating ? <CircularProgress size={20} /> : <AutoAwesome />}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{isGenerating
|
||||
? (locale === 'en' ? 'Generating...' : 'Se generează...')
|
||||
: (locale === 'en' ? 'Generate Prayer with AI' : 'Generează rugăciune cu AI')
|
||||
}
|
||||
</Button>
|
||||
|
||||
{newPrayer.title && newPrayer.description && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{locale === 'en'
|
||||
? 'Prayer generated! Switch to the "Write Prayer" tab to review and edit before submitting.'
|
||||
: 'Rugăciune generată! Comută la tabul "Scrie rugăciune" pentru a revizui și edita înainte de a trimite.'
|
||||
}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={newPrayer.isPublic}
|
||||
onChange={(event) => setNewPrayer({ ...newPrayer, isPublic: event.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={t('dialog.makePublic')}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{newPrayer.isPublic ? t('dialog.visibilityPublic') : t('dialog.visibilityPrivate')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenDialog(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmitPrayer}
|
||||
variant="contained"
|
||||
disabled={!newPrayer.title.trim() || !newPrayer.description.trim()}
|
||||
>
|
||||
{t('dialog.submit')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Auth Modal */}
|
||||
<AuthModal
|
||||
open={authModalOpen}
|
||||
onClose={() => setAuthModalOpen(false)}
|
||||
onSuccess={handleAuthSuccess}
|
||||
message={t('authRequired')}
|
||||
defaultTab="login"
|
||||
/>
|
||||
</Container>
|
||||
</Box>
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<h1>Prayer Wall Feature Disabled</h1>
|
||||
<p>This feature is currently disabled.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
38
app/api/payload/[...rest]/route.ts
Normal file
38
app/api/payload/[...rest]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getPayloadHMR } from '@payloadcms/next/utilities';
|
||||
import config from '@/payload.config';
|
||||
|
||||
let cachedPayload: any = null;
|
||||
|
||||
async function getPayload() {
|
||||
if (!cachedPayload) {
|
||||
cachedPayload = await getPayloadHMR({ config });
|
||||
}
|
||||
return cachedPayload;
|
||||
}
|
||||
|
||||
async function payloadHandler(req: Request) {
|
||||
const payload = await getPayload();
|
||||
return payload.handleRequest({
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return payloadHandler(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return payloadHandler(request);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
return payloadHandler(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
return payloadHandler(request);
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
return payloadHandler(request);
|
||||
}
|
||||
129
app/sitemap.ts
Normal file
129
app/sitemap.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 86400 // Revalidate once per day
|
||||
|
||||
const BASE_URL = 'https://biblical-guide.com'
|
||||
const LOCALES = ['en', 'ro', 'es', 'it']
|
||||
|
||||
// Map locales to Bible version languages
|
||||
const LOCALE_TO_LANGUAGE: Record<string, string> = {
|
||||
'en': 'en',
|
||||
'ro': 'ro',
|
||||
'es': 'es',
|
||||
'it': 'it'
|
||||
}
|
||||
|
||||
// Prioritized versions for each language (to limit sitemap size)
|
||||
const PRIORITY_VERSIONS: Record<string, string[]> = {
|
||||
'en': ['ENG-ASV', 'ENG-KJV', 'ENG-WEB', 'ENGKJVCPB', 'ENGEMTV'],
|
||||
'ro': ['ROO', 'RONDCV', 'ROCOR'],
|
||||
'es': ['SPAV1602P', 'SPABES', 'SPARVG', 'SPAPDDPT'],
|
||||
'it': ['ITNRV', 'ITPRV', 'ITCEI']
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const urls: MetadataRoute.Sitemap = []
|
||||
|
||||
// Static pages for each locale
|
||||
const staticPages = [
|
||||
{ path: '', priority: 1.0, changeFrequency: 'daily' as const },
|
||||
{ path: '/bible', priority: 0.9, changeFrequency: 'weekly' as const },
|
||||
{ path: '/prayers', priority: 0.8, changeFrequency: 'daily' as const },
|
||||
{ path: '/search', priority: 0.7, changeFrequency: 'weekly' as const },
|
||||
{ path: '/contact', priority: 0.6, changeFrequency: 'monthly' as const },
|
||||
{ path: '/donate', priority: 0.7, changeFrequency: 'monthly' as const },
|
||||
{ path: '/subscription', priority: 0.8, changeFrequency: 'weekly' as const },
|
||||
{ path: '/reading-plans', priority: 0.7, changeFrequency: 'weekly' as const },
|
||||
{ path: '/bookmarks', priority: 0.6, changeFrequency: 'weekly' as const },
|
||||
{ path: '/settings', priority: 0.5, changeFrequency: 'monthly' as const },
|
||||
{ path: '/profile', priority: 0.5, changeFrequency: 'monthly' as const },
|
||||
{ path: '/login', priority: 0.5, changeFrequency: 'monthly' as const },
|
||||
{ path: '/auth/login', priority: 0.5, changeFrequency: 'monthly' as const },
|
||||
]
|
||||
|
||||
// Add static pages for all locales
|
||||
for (const locale of LOCALES) {
|
||||
for (const page of staticPages) {
|
||||
urls.push({
|
||||
url: `${BASE_URL}/${locale}${page.path}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: page.changeFrequency,
|
||||
priority: page.priority,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Get priority Bible versions for each language
|
||||
for (const locale of LOCALES) {
|
||||
const language = LOCALE_TO_LANGUAGE[locale]
|
||||
const priorityAbbreviations = PRIORITY_VERSIONS[language] || []
|
||||
|
||||
// Get versions for this language (prioritize specific versions, then default, then by language)
|
||||
const versions = await prisma.bibleVersion.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ abbreviation: { in: priorityAbbreviations } },
|
||||
{ language: language, isDefault: true },
|
||||
{ language: language }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
abbreviation: true,
|
||||
isDefault: true,
|
||||
},
|
||||
take: 10, // Limit to top 10 versions per language
|
||||
orderBy: [
|
||||
{ isDefault: 'desc' },
|
||||
{ abbreviation: 'asc' }
|
||||
]
|
||||
})
|
||||
|
||||
console.log(`[Sitemap] Locale ${locale}: Found ${versions.length} relevant Bible versions`)
|
||||
|
||||
// For each version, get all books and chapters
|
||||
for (const version of versions) {
|
||||
const books = await prisma.bibleBook.findMany({
|
||||
where: { versionId: version.id },
|
||||
select: {
|
||||
id: true,
|
||||
bookKey: true,
|
||||
},
|
||||
orderBy: { orderNum: 'asc' },
|
||||
})
|
||||
|
||||
// Add URLs for each book and chapter
|
||||
for (const book of books) {
|
||||
const bookSlug = book.bookKey.toLowerCase()
|
||||
const versionSlug = version.abbreviation.toLowerCase()
|
||||
|
||||
// Get chapters for this book
|
||||
const chapters = await prisma.bibleChapter.findMany({
|
||||
where: { bookId: book.id },
|
||||
select: { chapterNum: true },
|
||||
orderBy: { chapterNum: 'asc' },
|
||||
})
|
||||
|
||||
// Add URL for each chapter (only for this locale to avoid duplicates)
|
||||
for (const chapter of chapters) {
|
||||
urls.push({
|
||||
url: `${BASE_URL}/${locale}/bible/${versionSlug}/${bookSlug}/${chapter.chapterNum}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: version.isDefault ? 0.7 : 0.6,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Sitemap] Generated ${urls.length} total URLs`)
|
||||
} catch (error) {
|
||||
console.error('[Sitemap] Error generating Bible URLs:', error)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
@@ -57,7 +57,7 @@ const menuItems = [
|
||||
{ text: 'Email Settings', icon: EmailIcon, href: '/admin/mailgun' },
|
||||
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
||||
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
||||
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
|
||||
// { text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' }, // AI Chat disabled
|
||||
{ text: 'Settings', icon: Settings, href: '/admin/settings' },
|
||||
];
|
||||
|
||||
|
||||
249
components/bible/bible-reader-app.tsx
Normal file
249
components/bible/bible-reader-app.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import { BibleChapter, BibleVerse } from '@/types'
|
||||
import { getCachedChapter, cacheChapter } from '@/lib/cache-manager'
|
||||
import { SearchNavigator } from './search-navigator'
|
||||
import { ReadingView } from './reading-view'
|
||||
import { VersDetailsPanel } from './verse-details-panel'
|
||||
import { ReadingSettings } from './reading-settings'
|
||||
|
||||
interface BookInfo {
|
||||
id: string // UUID
|
||||
orderNum: number
|
||||
bookKey: string
|
||||
name: string
|
||||
chapterCount: number
|
||||
}
|
||||
|
||||
export function BibleReaderApp() {
|
||||
const locale = useLocale()
|
||||
const [bookId, setBookId] = useState(1) // Genesis (numeric ID from search)
|
||||
const [chapter, setChapter] = useState(1)
|
||||
const [currentChapter, setCurrentChapter] = useState<BibleChapter | null>(null)
|
||||
const [selectedVerse, setSelectedVerse] = useState<BibleVerse | null>(null)
|
||||
const [detailsPanelOpen, setDetailsPanelOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set())
|
||||
const [books, setBooks] = useState<BookInfo[]>([])
|
||||
const [versionId, setVersionId] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [booksLoading, setBooksLoading] = useState(true)
|
||||
|
||||
// Load books on mount or when locale changes
|
||||
useEffect(() => {
|
||||
loadBooks()
|
||||
}, [locale])
|
||||
|
||||
// Load chapter when bookId or chapter changes
|
||||
useEffect(() => {
|
||||
if (!booksLoading && books.length > 0) {
|
||||
loadChapter(bookId, chapter)
|
||||
}
|
||||
}, [bookId, chapter, booksLoading, books.length])
|
||||
|
||||
async function loadBooks() {
|
||||
setBooksLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/bible/books?locale=${locale}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load books: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.books && Array.isArray(data.books)) {
|
||||
const bookMap: BookInfo[] = data.books.map((book: any) => ({
|
||||
id: book.id,
|
||||
orderNum: book.orderNum,
|
||||
bookKey: book.bookKey,
|
||||
name: book.name,
|
||||
chapterCount: book.chapters.length
|
||||
}))
|
||||
setBooks(bookMap)
|
||||
setVersionId(data.version?.id || 'unknown')
|
||||
} else {
|
||||
throw new Error('Invalid books response format')
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error loading books'
|
||||
setError(errorMsg)
|
||||
console.error('Error loading books:', error)
|
||||
} finally {
|
||||
setBooksLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChapter(numericBookId: number, chapterNum: number) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const book = books.find(b => b.orderNum === numericBookId)
|
||||
if (!book) {
|
||||
setError(`Book not found (ID: ${numericBookId})`)
|
||||
setCurrentChapter(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
const chapterId = `${book.id}-${chapterNum}`
|
||||
let data = await getCachedChapter(chapterId)
|
||||
|
||||
// If not cached, fetch from API
|
||||
if (!data) {
|
||||
const response = await fetch(`/api/bible/chapter?book=${book.id}&chapter=${chapterNum}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load chapter: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
data = json.chapter
|
||||
|
||||
// Cache it
|
||||
if (data) {
|
||||
data.id = chapterId
|
||||
await cacheChapter(data).catch(e => console.error('Cache error:', e))
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentChapter(data)
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error loading chapter'
|
||||
setError(errorMsg)
|
||||
setCurrentChapter(null)
|
||||
console.error('Error loading chapter:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerseClick = (verseId: string) => {
|
||||
const verse = currentChapter?.verses.find(v => v.id === verseId)
|
||||
if (verse) {
|
||||
setSelectedVerse(verse)
|
||||
setDetailsPanelOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleBookmark = () => {
|
||||
if (!selectedVerse) return
|
||||
const newBookmarks = new Set(bookmarks)
|
||||
if (newBookmarks.has(selectedVerse.id)) {
|
||||
newBookmarks.delete(selectedVerse.id)
|
||||
} else {
|
||||
newBookmarks.add(selectedVerse.id)
|
||||
}
|
||||
setBookmarks(newBookmarks)
|
||||
// TODO: Sync to backend in Phase 2
|
||||
console.log('Bookmarks updated:', Array.from(newBookmarks))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Persist bookmarks to localStorage
|
||||
const bookmarkArray = Array.from(bookmarks)
|
||||
localStorage.setItem('bible-reader-bookmarks', JSON.stringify(bookmarkArray))
|
||||
}, [bookmarks])
|
||||
|
||||
// On mount, load bookmarks from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('bible-reader-bookmarks')
|
||||
if (stored) {
|
||||
try {
|
||||
const bookmarkArray = JSON.parse(stored) as string[]
|
||||
setBookmarks(new Set(bookmarkArray))
|
||||
} catch (e) {
|
||||
console.error('Failed to load bookmarks:', e)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAddNote = (note: string) => {
|
||||
if (!selectedVerse) return
|
||||
// TODO: Save note to backend in Phase 2
|
||||
console.log(`Note for verse ${selectedVerse.id}:`, note)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'auto', overflow: 'hidden' }}>
|
||||
{/* Header with search */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 1,
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<SearchNavigator
|
||||
onNavigate={(newBookId, newChapter) => {
|
||||
setBookId(newBookId)
|
||||
setChapter(newChapter)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Reading area */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
{!booksLoading && error ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography color="error" variant="h6">{error}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => location.reload()}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Box>
|
||||
) : booksLoading ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>Initializing Bible reader...</Box>
|
||||
) : loading ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>Loading chapter...</Box>
|
||||
) : currentChapter ? (
|
||||
<ReadingView
|
||||
chapter={currentChapter}
|
||||
loading={loading}
|
||||
onPrevChapter={() => chapter > 1 && setChapter(chapter - 1)}
|
||||
onNextChapter={() => {
|
||||
const book = books.find(b => b.orderNum === bookId)
|
||||
if (book && chapter < book.chapterCount) {
|
||||
setChapter(chapter + 1)
|
||||
}
|
||||
}}
|
||||
onVerseClick={handleVerseClick}
|
||||
onSettingsOpen={() => setSettingsOpen(true)}
|
||||
hasPrevChapter={chapter > 1}
|
||||
hasNextChapter={(() => {
|
||||
const book = books.find(b => b.orderNum === bookId)
|
||||
return book ? chapter < book.chapterCount : false
|
||||
})()}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
Failed to load chapter. Please try again.
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Details panel */}
|
||||
<VersDetailsPanel
|
||||
verse={selectedVerse}
|
||||
isOpen={detailsPanelOpen}
|
||||
onClose={() => setDetailsPanelOpen(false)}
|
||||
isBookmarked={selectedVerse ? bookmarks.has(selectedVerse.id) : false}
|
||||
onToggleBookmark={handleToggleBookmark}
|
||||
onAddNote={handleAddNote}
|
||||
/>
|
||||
|
||||
{/* Settings panel */}
|
||||
{settingsOpen && (
|
||||
<ReadingSettings onClose={() => setSettingsOpen(false)} />
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
182
components/bible/reading-settings.tsx
Normal file
182
components/bible/reading-settings.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Paper, Typography, Button, Slider, FormControl, InputLabel, Select, MenuItem, useMediaQuery, useTheme, IconButton } from '@mui/material'
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { ReadingPreference } from '@/types'
|
||||
import { getPreset, loadPreferences, savePreferences } from '@/lib/reading-preferences'
|
||||
|
||||
const FONTS = [
|
||||
{ value: 'georgia', label: 'Georgia (Serif)' },
|
||||
{ value: 'merriweather', label: 'Merriweather (Serif)' },
|
||||
{ value: 'inter', label: 'Inter (Sans)' },
|
||||
{ value: 'atkinson', label: 'Atkinson (Dyslexia-friendly)' },
|
||||
]
|
||||
|
||||
interface ReadingSettingsProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReadingSettings({ onClose }: ReadingSettingsProps) {
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||
const [preferences, setPreferences] = useState<ReadingPreference>(loadPreferences())
|
||||
|
||||
// Reload preferences on mount
|
||||
useEffect(() => {
|
||||
setPreferences(loadPreferences())
|
||||
}, [])
|
||||
|
||||
const applyPreset = (presetName: string) => {
|
||||
const preset = getPreset(presetName as any)
|
||||
setPreferences(preset)
|
||||
savePreferences(preset)
|
||||
// Trigger a storage event to notify other components
|
||||
window.dispatchEvent(new Event('storage'))
|
||||
}
|
||||
|
||||
const handleChange = (key: keyof ReadingPreference, value: any) => {
|
||||
const updated: ReadingPreference = {
|
||||
...preferences,
|
||||
[key]: value,
|
||||
preset: 'custom' as const
|
||||
}
|
||||
setPreferences(updated)
|
||||
savePreferences(updated)
|
||||
// Trigger a storage event to notify other components
|
||||
window.dispatchEvent(new Event('storage'))
|
||||
}
|
||||
|
||||
const content = (
|
||||
<Box sx={{ p: 3, maxWidth: 400 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6">Reading Settings</Typography>
|
||||
<IconButton size="small" onClick={onClose} aria-label="Close settings">
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Presets */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Presets</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{['default', 'dyslexia', 'highContrast', 'minimal'].map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant={preferences.preset === preset ? 'contained' : 'outlined'}
|
||||
onClick={() => applyPreset(preset)}
|
||||
size="small"
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{preset === 'highContrast' ? 'High Contrast' : preset}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Font */}
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Font</InputLabel>
|
||||
<Select
|
||||
value={preferences.fontFamily}
|
||||
label="Font"
|
||||
onChange={(e) => handleChange('fontFamily', e.target.value)}
|
||||
>
|
||||
{FONTS.map((font) => (
|
||||
<MenuItem key={font.value} value={font.value}>
|
||||
{font.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Font Size */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">Size: {preferences.fontSize}px</Typography>
|
||||
<Slider
|
||||
value={preferences.fontSize}
|
||||
onChange={(_, value) => handleChange('fontSize', value)}
|
||||
min={12}
|
||||
max={32}
|
||||
step={1}
|
||||
marks={[
|
||||
{ value: 12, label: '12' },
|
||||
{ value: 22, label: '22' },
|
||||
{ value: 32, label: '32' },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Line Height */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">Line Height: {preferences.lineHeight.toFixed(1)}x</Typography>
|
||||
<Slider
|
||||
value={preferences.lineHeight}
|
||||
onChange={(_, value) => handleChange('lineHeight', value)}
|
||||
min={1.4}
|
||||
max={2.2}
|
||||
step={0.1}
|
||||
marks={[
|
||||
{ value: 1.4, label: '1.4' },
|
||||
{ value: 1.8, label: '1.8' },
|
||||
{ value: 2.2, label: '2.2' },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Background Color */}
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Background</InputLabel>
|
||||
<Select
|
||||
value={preferences.backgroundColor}
|
||||
label="Background"
|
||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
||||
>
|
||||
<MenuItem value="#faf8f3">Warm</MenuItem>
|
||||
<MenuItem value="#ffffff">White</MenuItem>
|
||||
<MenuItem value="#f5f5f5">Light Gray</MenuItem>
|
||||
<MenuItem value="#1a1a1a">Dark</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxHeight: '80vh',
|
||||
backgroundColor: 'white',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
zIndex: 100,
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 -4px 20px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 400,
|
||||
zIndex: 100,
|
||||
borderRadius: 0,
|
||||
overflow: 'auto',
|
||||
boxShadow: '-4px 0 20px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
192
components/bible/reading-view.tsx
Normal file
192
components/bible/reading-view.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
import { useState, useEffect, CSSProperties } from 'react'
|
||||
import { Box, Typography, IconButton, Paper, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { NavigateBefore, NavigateNext, Settings as SettingsIcon } from '@mui/icons-material'
|
||||
import { BibleChapter } from '@/types'
|
||||
import { getCSSVariables, loadPreferences } from '@/lib/reading-preferences'
|
||||
|
||||
interface ReadingViewProps {
|
||||
chapter: BibleChapter
|
||||
loading: boolean
|
||||
onPrevChapter: () => void
|
||||
onNextChapter: () => void
|
||||
onVerseClick: (verseId: string) => void
|
||||
onSettingsOpen: () => void
|
||||
hasPrevChapter: boolean
|
||||
hasNextChapter: boolean
|
||||
}
|
||||
|
||||
export function ReadingView({
|
||||
chapter,
|
||||
loading,
|
||||
onPrevChapter,
|
||||
onNextChapter,
|
||||
onVerseClick,
|
||||
onSettingsOpen,
|
||||
hasPrevChapter,
|
||||
hasNextChapter,
|
||||
}: ReadingViewProps) {
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||
const [preferences, setPreferences] = useState(loadPreferences())
|
||||
const [showControls, setShowControls] = useState(!isMobile)
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorageChange = () => {
|
||||
setPreferences(loadPreferences())
|
||||
}
|
||||
|
||||
setPreferences(loadPreferences())
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
|
||||
return () => window.removeEventListener('storage', handleStorageChange)
|
||||
}, [])
|
||||
|
||||
const cssVars = getCSSVariables(preferences)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<Typography>Loading chapter...</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
...cssVars,
|
||||
backgroundColor: 'var(--bg-color)',
|
||||
color: 'var(--text-color)',
|
||||
minHeight: '100vh',
|
||||
transition: 'background-color 0.2s, color 0.2s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
} as CSSProperties}
|
||||
onClick={(e) => {
|
||||
if (isMobile) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const y = e.clientY - rect.top
|
||||
if (y < rect.height * 0.3) {
|
||||
setShowControls(true)
|
||||
} else if (y > rect.height * 0.7) {
|
||||
setShowControls(!showControls)
|
||||
} else {
|
||||
setShowControls(false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
{(showControls || !isMobile) && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'inherit',
|
||||
borderBottom: `1px solid var(--text-color)`,
|
||||
opacity: 0.7
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" fontWeight={600}>
|
||||
{chapter.bookName} {chapter.chapter}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Main Text Area */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 3,
|
||||
maxWidth: 700,
|
||||
mx: 'auto',
|
||||
width: '100%',
|
||||
px: 'var(--margin-width)',
|
||||
lineHeight: 'var(--line-height)',
|
||||
fontSize: 'var(--font-size)',
|
||||
fontFamily: 'var(--font-family)',
|
||||
textAlign: 'var(--text-align)' as any,
|
||||
} as CSSProperties}
|
||||
>
|
||||
{chapter.verses.map((verse) => (
|
||||
<span
|
||||
key={verse.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Verse ${verse.verseNum}: ${verse.text}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onVerseClick(verse.id)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onVerseClick(verse.id)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(255, 193, 7, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
<sup style={{ fontSize: '0.8em', marginRight: '0.25em', fontWeight: 600, opacity: 0.6 }}>
|
||||
{verse.verseNum}
|
||||
</sup>
|
||||
{verse.text}{' '}
|
||||
</span>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Navigation Footer */}
|
||||
{(showControls || !isMobile) && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'inherit',
|
||||
borderTop: `1px solid var(--text-color)`,
|
||||
opacity: 0.7,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={onPrevChapter}
|
||||
disabled={!hasPrevChapter}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
>
|
||||
<NavigateBefore />
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="body2">
|
||||
Chapter {chapter.chapter}
|
||||
</Typography>
|
||||
|
||||
<IconButton
|
||||
onClick={onSettingsOpen}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
onClick={onNextChapter}
|
||||
disabled={!hasNextChapter}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
>
|
||||
<NavigateNext />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
104
components/bible/search-navigator.tsx
Normal file
104
components/bible/search-navigator.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, Close } from '@mui/icons-material'
|
||||
import { Box, TextField, InputAdornment, Paper, List, ListItem, ListItemButton, Typography } from '@mui/material'
|
||||
import { searchBooks, type SearchResult } from '@/lib/bible-search'
|
||||
|
||||
interface SearchNavigatorProps {
|
||||
onNavigate: (bookId: number, chapter: number) => void
|
||||
}
|
||||
|
||||
export function SearchNavigator({ onNavigate }: SearchNavigatorProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim()) {
|
||||
setResults(searchBooks(query))
|
||||
setIsOpen(true)
|
||||
} else {
|
||||
setResults([])
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
const handleSelect = (result: SearchResult) => {
|
||||
onNavigate(result.bookId, result.chapter)
|
||||
setQuery('')
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: '100%' }}>
|
||||
<TextField
|
||||
aria-label="Search Bible books and chapters"
|
||||
role="searchbox"
|
||||
placeholder="Search Bible (e.g., Genesis 1, John 3)"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => query && setIsOpen(true)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search sx={{ color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: query && (
|
||||
<InputAdornment position="end">
|
||||
<Close
|
||||
sx={{ cursor: 'pointer', color: 'text.secondary' }}
|
||||
onClick={() => setQuery('')}
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
width: '100%',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontSize: '0.95rem',
|
||||
'@media (max-width: 600px)': {
|
||||
fontSize: '1rem' // Larger on mobile to avoid zoom
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isOpen && results.length > 0 && (
|
||||
<Paper
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
mt: 1,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
{results.map((result, idx) => (
|
||||
<ListItem key={idx} disablePadding>
|
||||
<ListItemButton
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
sx={{ minHeight: '44px', py: 1.5 }}
|
||||
onClick={() => handleSelect(result)}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{result.reference}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
178
components/bible/verse-details-panel.tsx
Normal file
178
components/bible/verse-details-panel.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Paper, Typography, Tabs, Tab, IconButton, useMediaQuery, useTheme, TextField, Button } from '@mui/material'
|
||||
import { Close, Bookmark, BookmarkBorder } from '@mui/icons-material'
|
||||
import { BibleVerse } from '@/types'
|
||||
|
||||
interface VersDetailsPanelProps {
|
||||
verse: BibleVerse | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isBookmarked: boolean
|
||||
onToggleBookmark: () => void
|
||||
onAddNote: (note: string) => void
|
||||
}
|
||||
|
||||
export function VersDetailsPanel({
|
||||
verse,
|
||||
isOpen,
|
||||
onClose,
|
||||
isBookmarked,
|
||||
onToggleBookmark,
|
||||
onAddNote,
|
||||
}: VersDetailsPanelProps) {
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||
const [tabValue, setTabValue] = useState(0)
|
||||
const [noteText, setNoteText] = useState('')
|
||||
|
||||
// Reset to Notes tab when verse changes
|
||||
useEffect(() => {
|
||||
setTabValue(0)
|
||||
}, [verse?.id])
|
||||
|
||||
if (!verse || !isOpen) return null
|
||||
|
||||
const handleAddNote = () => {
|
||||
if (noteText.trim()) {
|
||||
onAddNote(noteText)
|
||||
setNoteText('')
|
||||
}
|
||||
}
|
||||
|
||||
const PanelContent = (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{/* Verse Header */}
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} id="verse-details-header">
|
||||
{verse.chapter?.book?.name} {verse.chapter?.chapterNum}:{verse.verseNum}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
aria-label="Close verse details"
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Verse Text */}
|
||||
<Paper sx={{ p: 2, mb: 2, bgcolor: 'grey.100' }} elevation={0}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontStyle: 'italic' }}>
|
||||
{verse.text}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Bookmark Button */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
aria-label={isBookmarked ? 'Remove bookmark' : 'Add bookmark'}
|
||||
startIcon={isBookmarked ? <Bookmark /> : <BookmarkBorder />}
|
||||
onClick={onToggleBookmark}
|
||||
variant={isBookmarked ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
fullWidth={isMobile}
|
||||
>
|
||||
{isBookmarked ? 'Bookmarked' : 'Bookmark'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={(_, newValue) => setTabValue(newValue)}
|
||||
variant={isMobile ? 'fullWidth' : 'standard'}
|
||||
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab label="Notes" />
|
||||
<Tab label="Highlights" />
|
||||
<Tab label="References" />
|
||||
</Tabs>
|
||||
|
||||
{/* Tab Content */}
|
||||
<Box sx={{ pt: 2 }}>
|
||||
{tabValue === 0 && (
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="Add a note..."
|
||||
aria-label="Note text"
|
||||
helperText={`${noteText.length}/500 characters`}
|
||||
inputProps={{ maxLength: 500 }}
|
||||
value={noteText}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
size="small"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleAddNote}
|
||||
disabled={!noteText.trim()}
|
||||
>
|
||||
Save Note
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tabValue === 1 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Highlight colors coming soon
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{tabValue === 2 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Cross-references coming soon
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="verse-details-header"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
maxHeight: '70vh',
|
||||
backgroundColor: 'white',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
boxShadow: '0 -4px 20px rgba(0,0,0,0.1)',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{PanelContent}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 350,
|
||||
zIndex: 100,
|
||||
borderRadius: 0,
|
||||
boxShadow: '-4px 0 20px rgba(0,0,0,0.1)',
|
||||
overflow: 'auto',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
>
|
||||
{PanelContent}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -78,7 +78,8 @@ export function Navigation() {
|
||||
const basePages = [
|
||||
{ name: t('home'), path: '/', icon: <Home /> },
|
||||
{ name: t('bible'), path: '/bible', icon: <MenuBook /> },
|
||||
{ name: t('prayers'), path: '/prayers', icon: <Prayer /> },
|
||||
// DISABLED: Prayer Wall Feature
|
||||
// { name: t('prayers'), path: '/prayers', icon: <Prayer /> },
|
||||
{ name: t('search'), path: '/search', icon: <Search /> },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
// DISABLED: Prayer Wall Feature
|
||||
/* 'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Heart, Send } from 'lucide-react'
|
||||
@@ -185,4 +186,13 @@ export function PrayerWall() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} */
|
||||
|
||||
export function PrayerWall() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<h2>Prayer Wall Feature Disabled</h2>
|
||||
<p>This feature is currently disabled.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,8 +72,9 @@ export function Navigation() {
|
||||
|
||||
const navItems = [
|
||||
{ id: 'bible', label: 'Biblia', icon: Book },
|
||||
{ id: 'chat', label: 'Chat AI', icon: MessageCircle },
|
||||
{ id: 'prayers', label: 'Rugăciuni', icon: Heart },
|
||||
// { id: 'chat', label: 'Chat AI', icon: MessageCircle }, // AI Chat disabled
|
||||
// DISABLED: Prayer Wall Feature
|
||||
// { id: 'prayers', label: 'Rugăciuni', icon: Heart },
|
||||
{ id: 'search', label: 'Căutare', icon: Search },
|
||||
]
|
||||
|
||||
|
||||
288
docs/plans/2025-01-11-bible-reader-2025-design.md
Normal file
288
docs/plans/2025-01-11-bible-reader-2025-design.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 2025 Modern Bible Reader Design
|
||||
|
||||
**Date**: 2025-01-11
|
||||
**Status**: Approved Design
|
||||
**Objective**: Create a state-of-the-art, distraction-free Bible reader with comprehensive customization, offline-first capability, and seamless sync across devices.
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
The Bible reader is built around three non-negotiable principles:
|
||||
|
||||
1. **Content-first design**: Text is the hero; everything else supports it
|
||||
2. **Progressive disclosure**: Basic reading is immediately accessible; advanced features reveal on demand
|
||||
3. **Smart offline-first**: Works seamlessly online and offline with automatic sync
|
||||
|
||||
---
|
||||
|
||||
## Key Features & Requirements
|
||||
|
||||
### 1. Reading Interface & Layout
|
||||
|
||||
#### Desktop/Tablet Layout
|
||||
- **Header**: Minimal - book/chapter reference and reading time estimate (collapses on tablet)
|
||||
- **Main text area**: Full width, centered column for readability, generous margins, scalable fonts
|
||||
- **Right sidebar**: Chapter overview, verse numbers on hover (collapsible on tablet)
|
||||
- **Bottom bar**: Navigation controls (previous/next, search, settings) - subtle and de-emphasized
|
||||
|
||||
#### Mobile Layout
|
||||
- **Full-screen text** with header and footer hidden until needed
|
||||
- **Swipe left/right**: Navigate chapters (intuitive, touch-native)
|
||||
- **Tap top third**: Show header; tap bottom third: show navigation controls
|
||||
- **Search button**: Always available in floating action button (bottom right)
|
||||
|
||||
#### Touch-Optimized Navigation
|
||||
- **Tap verse reference** (e.g., "Genesis 1:1") → Search input pre-filled
|
||||
- **Keyboard**: Type book name or chapter reference with auto-complete suggestions
|
||||
- **Results**: Touch-friendly list with book icons, chapter counts, quick jump buttons
|
||||
- **Verse numbers**: Large tap targets (min 48px height on mobile)
|
||||
|
||||
### 2. Reading Customization System
|
||||
|
||||
#### Smart Preset Profiles (4 curated options)
|
||||
|
||||
**Default**: Serif font (Georgia/EB Garamond), comfortable line-height, warm background, optimized spacing
|
||||
|
||||
**Dyslexia-friendly**: Dyslexia-optimized font (e.g., Atkinson Hyperlegible), increased letter spacing, sans-serif, larger default size, muted colors
|
||||
|
||||
**High contrast**: Bold sans-serif, maximum contrast, dark background with bright text or vice versa, minimalist
|
||||
|
||||
**Minimal**: Smallest overhead, pure black text on white, no decorative elements
|
||||
|
||||
#### Full Customization Options
|
||||
Users can fine-tune any preset:
|
||||
- **Font family**: 6-8 curated options (serif, sans-serif, dyslexia-friendly)
|
||||
- **Font size**: 12px-32px with live preview
|
||||
- **Line height**: 1.4x - 2.2x for readability
|
||||
- **Letter spacing**: Normal to 0.15em for spacing
|
||||
- **Text alignment**: Left (default), justified, center
|
||||
- **Background color**: Light, warm, sepia, dark mode, custom
|
||||
- **Text color**: Auto-adjusted based on background for contrast
|
||||
- **Margins**: Narrow, normal, wide (affects text width/readability)
|
||||
|
||||
#### Customization Persistence
|
||||
- Stored in localStorage (device)
|
||||
- Synced to cloud (user account)
|
||||
- Live preview as user adjusts sliders
|
||||
- "Reset to preset" button always available
|
||||
- Custom profiles can be saved
|
||||
|
||||
### 3. Layered Details Panel & Annotations
|
||||
|
||||
**Design principle**: Main text stays clean; detailed information reveals on demand.
|
||||
|
||||
#### Panel Behavior
|
||||
- Triggered by clicking/tapping a verse
|
||||
- Appears as right sidebar (desktop) or bottom sheet (mobile)
|
||||
- Verse reference sticky at top, always visible
|
||||
|
||||
#### Tabs/Accordions
|
||||
- **Notes**: Rich text editor inline, add/edit without leaving reading flow
|
||||
- **Highlights**: Color-coded (yellow, orange, pink, blue), one swipe to highlight, another to change color
|
||||
- **Cross-References**: Collapsible list showing related verses, tap to jump
|
||||
- **Commentary**: Expandable summaries, lazy-loaded, tap to expand full text
|
||||
|
||||
#### Annotation Features
|
||||
- **Bookmarks**: One-tap heart icon to mark verses as important
|
||||
- **Highlights**: Auto-saved with timestamp, searchable across all highlights
|
||||
- **Personal Notes**: Rich text editor with optional voice-to-text (mobile)
|
||||
- **Cross-References**: System generates suggestions, user can add custom links
|
||||
- **Sync behavior**: All annotations sync automatically when online, queued offline
|
||||
|
||||
### 4. Smart Offline & Sync Strategy
|
||||
|
||||
#### Caching Approach (Not Full Downloads)
|
||||
- **On read**: When user opens a chapter, it's cached to IndexedDB
|
||||
- **Prefetching**: Automatically cache next 2-3 chapters in background
|
||||
- **Cache management**: Keep last 50 chapters read (~5-10MB typical)
|
||||
- **Storage limit**: 50MB mobile, 200MB desktop
|
||||
- **Expiration**: Auto-expire chapters after 30 days or when quota exceeded
|
||||
|
||||
#### Online/Offline Detection
|
||||
- Service Worker monitors connection status
|
||||
- Seamless switching between online and offline modes
|
||||
- Status indicator in header: green (online), yellow (syncing), gray (offline)
|
||||
- User can force offline-mode for distraction-free reading
|
||||
|
||||
#### Automatic Sync Queue
|
||||
- All annotations queued locally on creation
|
||||
- Auto-sync to server when connection detected
|
||||
- Reading position synced every 30 seconds when online
|
||||
- Sync status: "Syncing...", then "Synced ✓" briefly shown
|
||||
- User can manually trigger sync from settings
|
||||
|
||||
#### Conflict Resolution
|
||||
- **Strategy**: Last-modified-timestamp wins
|
||||
- **Safety**: No data loss - version history kept server-side
|
||||
- **Warning**: User notified if sync fails (rare), manual sync available
|
||||
- **User control**: Toggle "offline-first mode" to disable auto-sync
|
||||
|
||||
### 5. Component Architecture
|
||||
|
||||
```
|
||||
BibleReaderApp (main container)
|
||||
├── SearchNavigator (search + auto-complete, touch-optimized)
|
||||
├── ReadingView (responsive layout management)
|
||||
│ ├── Header (book/chapter reference, reading time)
|
||||
│ ├── MainContent (centered text column)
|
||||
│ │ └── VerseRenderer (verse numbers, highlighting, click handling)
|
||||
│ └── NavFooter (prev/next, mobile controls)
|
||||
├── VersDetailsPanel (reveals on verse click)
|
||||
│ ├── TabsContainer
|
||||
│ │ ├── NotesTab (rich editor)
|
||||
│ │ ├── HighlightsTab (color selection)
|
||||
│ │ ├── CrossRefsTab (linked verses)
|
||||
│ │ └── CommentaryTab (lazy-loaded)
|
||||
├── ReadingSettings (customization presets + sliders)
|
||||
├── OfflineSyncManager (background sync, status indicator)
|
||||
└── ServiceWorkerManager (offline detection, cache strategies)
|
||||
```
|
||||
|
||||
### 6. State Management & Data Flow
|
||||
|
||||
#### Local Storage
|
||||
- Current book/chapter/verse position
|
||||
- User reading preferences (font, size, colors, etc.)
|
||||
- Custom preset names and settings
|
||||
|
||||
#### IndexedDB
|
||||
- Cached Bible chapters with expiration timestamps
|
||||
- All annotations: bookmarks, highlights, notes
|
||||
- Sync queue (pending changes)
|
||||
- Reading history
|
||||
|
||||
#### Cloud/Server
|
||||
- Master copy of user data (preferences, annotations)
|
||||
- Reconciles with local state on sync
|
||||
- Manages version history for conflict resolution
|
||||
- Provides commentary and cross-reference data
|
||||
|
||||
#### Data Flow Sequence
|
||||
1. User opens app → Check IndexedDB for cached chapter
|
||||
2. If cached and fresh, render immediately (instant UX)
|
||||
3. Fetch fresh version from server in background (if online)
|
||||
4. User reads, annotations stored locally with sync timestamp
|
||||
5. Background sync worker pushes changes when connection available
|
||||
6. Service Worker manages cache invalidation and offline fallback
|
||||
|
||||
### 7. Error Handling & Resilience
|
||||
|
||||
- **Network failures**: Toast notification, automatic retry queue
|
||||
- **Sync conflicts**: Timestamp-based resolution, log for user review
|
||||
- **Corrupted cache**: Auto-clear and re-fetch from server
|
||||
- **Quota exceeded**: Prompt user to clear old cached chapters
|
||||
- **Service Worker issues**: Graceful fallback to online-only mode
|
||||
|
||||
### 8. Success Metrics
|
||||
|
||||
- **Performance**: First render < 500ms (cached), < 1.5s (fresh fetch)
|
||||
- **Accessibility**: WCAG 2.1 AA compliance
|
||||
- **Mobile**: Touch targets min 48px, responsive down to 320px width
|
||||
- **Offline**: Works without internet for last 50 chapters read
|
||||
- **Sync**: Auto-sync completes within 5 seconds when online
|
||||
- **User satisfaction**: Dyslexia-friendly preset reduces reading friction
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions Rationale
|
||||
|
||||
### Why Smart Caching Over Full Downloads?
|
||||
- Reduces initial storage requirements (50MB vs 100+MB for full Bible)
|
||||
- Users only cache what they actually read
|
||||
- Simpler UX: no complex download management
|
||||
- Works great for mobile with limited storage
|
||||
|
||||
### Why Presets + Full Customization?
|
||||
- Accessibility: Preset handles 90% of needs, reduces choice paralysis
|
||||
- Power users: Full control when needed
|
||||
- Discovery: Users learn what customization options exist through presets
|
||||
- Inclusivity: Dyslexia preset built-in, not an afterthought
|
||||
|
||||
### Why Layered Panel for Details?
|
||||
- Keeps reading flow uninterrupted
|
||||
- Details don't clutter main text
|
||||
- Touch-friendly: panel slides in from bottom on mobile
|
||||
- Scalable: easy to add more annotation features later
|
||||
|
||||
### Why Search-First Navigation?
|
||||
- Fastest for known passages (type "Genesis 1" instantly)
|
||||
- Modern pattern: matches how users navigate other apps
|
||||
- Mobile-friendly: better than scrolling long book lists
|
||||
- Supports reference system: users familiar with biblical citations
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priorities
|
||||
|
||||
### Phase 1 (MVP): Core Reading Experience
|
||||
- Search-first navigation
|
||||
- Responsive reading layout (desktop, tablet, mobile)
|
||||
- Basic customization (presets only)
|
||||
- Verse highlighting and basic bookmarks
|
||||
- Simple offline support (cache as read)
|
||||
|
||||
### Phase 2: Rich Annotations
|
||||
- Notes editor
|
||||
- Color-coded highlights
|
||||
- Cross-references
|
||||
- Auto-sync (offline/online detection)
|
||||
|
||||
### Phase 3: Polish & Advanced
|
||||
- Commentary integration
|
||||
- Smart linking (theological themes)
|
||||
- Advanced customization (full sliders)
|
||||
- Sync conflict resolution
|
||||
- Analytics and reading history
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Search filtering and auto-complete logic
|
||||
- Sync queue management and conflict resolution
|
||||
- Cache expiration logic
|
||||
- Customization preset application
|
||||
|
||||
### Integration Tests
|
||||
- Online/offline switching
|
||||
- Cache hit/miss behavior
|
||||
- Annotation persistence across sessions
|
||||
- Sync conflict resolution
|
||||
|
||||
### E2E Tests
|
||||
- Complete reading flow (search → read → bookmark → sync)
|
||||
- Offline reading with sync on reconnection
|
||||
- Cross-device sync behavior
|
||||
- Touch navigation on mobile
|
||||
- Customization persistence
|
||||
|
||||
### Manual Testing
|
||||
- Desktop browsers (Chrome, Firefox, Safari)
|
||||
- Mobile Safari (iOS)
|
||||
- Chrome Mobile (Android)
|
||||
- Tablet layouts (iPad, Android tablets)
|
||||
- Network throttling (fast 3G, slow 3G, offline)
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Voice reading (text-to-speech)
|
||||
- Reading plans integration
|
||||
- Social sharing (annotated verses)
|
||||
- Collaborative notes (study groups)
|
||||
- Advanced search (full-text, by topic)
|
||||
- Statistics dashboard (chapters read, time spent)
|
||||
- Dark mode improvements (true black on OLED)
|
||||
- Predictive prefetching (learns reading patterns)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Current implementation: `/root/biblical-guide/components/bible/reader.tsx`
|
||||
- Offline support (started): `/root/biblical-guide/components/bible/offline-bible-reader.tsx`
|
||||
- Type definitions: `/root/biblical-guide/types/index.ts`
|
||||
- API endpoints: `/root/biblical-guide/app/api/bible/`
|
||||
1418
docs/plans/2025-01-11-bible-reader-2025-implementation.md
Normal file
1418
docs/plans/2025-01-11-bible-reader-2025-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
143
lib/bible-search.ts
Normal file
143
lib/bible-search.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Bible books data with abbreviations
|
||||
const BIBLE_BOOKS = [
|
||||
// Old Testament
|
||||
{ id: 1, name: 'Genesis', abbr: 'Gen', chapters: 50 },
|
||||
{ id: 2, name: 'Exodus', abbr: 'Ex', chapters: 40 },
|
||||
{ id: 3, name: 'Leviticus', abbr: 'Lev', chapters: 27 },
|
||||
{ id: 4, name: 'Numbers', abbr: 'Num', chapters: 36 },
|
||||
{ id: 5, name: 'Deuteronomy', abbr: 'Deut', chapters: 34 },
|
||||
{ id: 6, name: 'Joshua', abbr: 'Josh', chapters: 24 },
|
||||
{ id: 7, name: 'Judges', abbr: 'Judg', chapters: 21 },
|
||||
{ id: 8, name: 'Ruth', abbr: 'Ruth', chapters: 4 },
|
||||
{ id: 9, name: '1 Samuel', abbr: '1Sam', chapters: 31 },
|
||||
{ id: 10, name: '2 Samuel', abbr: '2Sam', chapters: 24 },
|
||||
{ id: 11, name: '1 Kings', abbr: '1Kgs', chapters: 22 },
|
||||
{ id: 12, name: '2 Kings', abbr: '2Kgs', chapters: 25 },
|
||||
{ id: 13, name: '1 Chronicles', abbr: '1Chr', chapters: 29 },
|
||||
{ id: 14, name: '2 Chronicles', abbr: '2Chr', chapters: 36 },
|
||||
{ id: 15, name: 'Ezra', abbr: 'Ezra', chapters: 10 },
|
||||
{ id: 16, name: 'Nehemiah', abbr: 'Neh', chapters: 13 },
|
||||
{ id: 17, name: 'Esther', abbr: 'Esth', chapters: 10 },
|
||||
{ id: 18, name: 'Job', abbr: 'Job', chapters: 42 },
|
||||
{ id: 19, name: 'Psalms', abbr: 'Ps', chapters: 150 },
|
||||
{ id: 20, name: 'Proverbs', abbr: 'Prov', chapters: 31 },
|
||||
{ id: 21, name: 'Ecclesiastes', abbr: 'Eccl', chapters: 12 },
|
||||
{ id: 22, name: 'Song of Solomon', abbr: 'Song', chapters: 8 },
|
||||
{ id: 23, name: 'Isaiah', abbr: 'Isa', chapters: 66 },
|
||||
{ id: 24, name: 'Jeremiah', abbr: 'Jer', chapters: 52 },
|
||||
{ id: 25, name: 'Lamentations', abbr: 'Lam', chapters: 5 },
|
||||
{ id: 26, name: 'Ezekiel', abbr: 'Ezek', chapters: 48 },
|
||||
{ id: 27, name: 'Daniel', abbr: 'Dan', chapters: 12 },
|
||||
{ id: 28, name: 'Hosea', abbr: 'Hos', chapters: 14 },
|
||||
{ id: 29, name: 'Joel', abbr: 'Joel', chapters: 3 },
|
||||
{ id: 30, name: 'Amos', abbr: 'Amos', chapters: 9 },
|
||||
{ id: 31, name: 'Obadiah', abbr: 'Obad', chapters: 1 },
|
||||
{ id: 32, name: 'Jonah', abbr: 'Jonah', chapters: 4 },
|
||||
{ id: 33, name: 'Micah', abbr: 'Mic', chapters: 7 },
|
||||
{ id: 34, name: 'Nahum', abbr: 'Nah', chapters: 3 },
|
||||
{ id: 35, name: 'Habakkuk', abbr: 'Hab', chapters: 3 },
|
||||
{ id: 36, name: 'Zephaniah', abbr: 'Zeph', chapters: 3 },
|
||||
{ id: 37, name: 'Haggai', abbr: 'Hag', chapters: 2 },
|
||||
{ id: 38, name: 'Zechariah', abbr: 'Zech', chapters: 14 },
|
||||
{ id: 39, name: 'Malachi', abbr: 'Mal', chapters: 4 },
|
||||
// New Testament
|
||||
{ id: 40, name: 'Matthew', abbr: 'Matt', chapters: 28 },
|
||||
{ id: 41, name: 'Mark', abbr: 'Mark', chapters: 16 },
|
||||
{ id: 42, name: 'Luke', abbr: 'Luke', chapters: 24 },
|
||||
{ id: 43, name: 'John', abbr: 'John', chapters: 21 },
|
||||
{ id: 44, name: 'Acts', abbr: 'Acts', chapters: 28 },
|
||||
{ id: 45, name: 'Romans', abbr: 'Rom', chapters: 16 },
|
||||
{ id: 46, name: '1 Corinthians', abbr: '1Cor', chapters: 16 },
|
||||
{ id: 47, name: '2 Corinthians', abbr: '2Cor', chapters: 13 },
|
||||
{ id: 48, name: 'Galatians', abbr: 'Gal', chapters: 6 },
|
||||
{ id: 49, name: 'Ephesians', abbr: 'Eph', chapters: 6 },
|
||||
{ id: 50, name: 'Philippians', abbr: 'Phil', chapters: 4 },
|
||||
{ id: 51, name: 'Colossians', abbr: 'Col', chapters: 4 },
|
||||
{ id: 52, name: '1 Thessalonians', abbr: '1Thess', chapters: 5 },
|
||||
{ id: 53, name: '2 Thessalonians', abbr: '2Thess', chapters: 3 },
|
||||
{ id: 54, name: '1 Timothy', abbr: '1Tim', chapters: 6 },
|
||||
{ id: 55, name: '2 Timothy', abbr: '2Tim', chapters: 4 },
|
||||
{ id: 56, name: 'Titus', abbr: 'Titus', chapters: 3 },
|
||||
{ id: 57, name: 'Philemon', abbr: 'Phlm', chapters: 1 },
|
||||
{ id: 58, name: 'Hebrews', abbr: 'Heb', chapters: 13 },
|
||||
{ id: 59, name: 'James', abbr: 'Jas', chapters: 5 },
|
||||
{ id: 60, name: '1 Peter', abbr: '1Pet', chapters: 5 },
|
||||
{ id: 61, name: '2 Peter', abbr: '2Pet', chapters: 3 },
|
||||
{ id: 62, name: '1 John', abbr: '1John', chapters: 5 },
|
||||
{ id: 63, name: '2 John', abbr: '2John', chapters: 1 },
|
||||
{ id: 64, name: '3 John', abbr: '3John', chapters: 1 },
|
||||
{ id: 65, name: 'Jude', abbr: 'Jude', chapters: 1 },
|
||||
{ id: 66, name: 'Revelation', abbr: 'Rev', chapters: 22 }
|
||||
]
|
||||
|
||||
export interface SearchResult {
|
||||
bookId: number
|
||||
bookName: string
|
||||
chapter: number
|
||||
reference: string
|
||||
}
|
||||
|
||||
export function searchBooks(query: string): SearchResult[] {
|
||||
if (!query.trim()) return []
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const results: SearchResult[] = []
|
||||
|
||||
// Try to parse as "Book Chapter" format (e.g., "Genesis 1", "Gen 1")
|
||||
const refMatch = query.match(/^([a-z\s]+)\s*(\d+)?/i)
|
||||
if (refMatch) {
|
||||
const bookQuery = refMatch[1].toLowerCase().trim()
|
||||
const chapterNum = refMatch[2] ? parseInt(refMatch[2]) : 1
|
||||
|
||||
for (const book of BIBLE_BOOKS) {
|
||||
if (book.name.toLowerCase().startsWith(bookQuery) ||
|
||||
book.abbr.toLowerCase().startsWith(bookQuery)) {
|
||||
if (chapterNum <= book.chapters) {
|
||||
results.push({
|
||||
bookId: book.id,
|
||||
bookName: book.name,
|
||||
chapter: chapterNum,
|
||||
reference: `${book.name} ${chapterNum}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy match on book names if exact prefix didn't work
|
||||
if (results.length === 0) {
|
||||
for (const book of BIBLE_BOOKS) {
|
||||
if (book.name.toLowerCase().includes(lowerQuery) ||
|
||||
book.abbr.toLowerCase().includes(lowerQuery)) {
|
||||
results.push({
|
||||
bookId: book.id,
|
||||
bookName: book.name,
|
||||
chapter: 1,
|
||||
reference: book.name
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, 10) // Return top 10
|
||||
}
|
||||
|
||||
export function parseReference(ref: string): { bookId: number; chapter: number } | null {
|
||||
const match = ref.match(/^([a-z\s]+)\s*(\d+)?/i)
|
||||
if (!match) return null
|
||||
|
||||
const bookQuery = match[1].toLowerCase().trim()
|
||||
const chapterNum = match[2] ? parseInt(match[2]) : 1
|
||||
|
||||
for (const book of BIBLE_BOOKS) {
|
||||
if (book.name.toLowerCase().startsWith(bookQuery) ||
|
||||
book.abbr.toLowerCase().startsWith(bookQuery)) {
|
||||
return {
|
||||
bookId: book.id,
|
||||
chapter: Math.max(1, Math.min(chapterNum, book.chapters))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
124
lib/cache-manager.ts
Normal file
124
lib/cache-manager.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// IndexedDB cache management
|
||||
import { BibleChapter, CacheEntry } from '@/types'
|
||||
|
||||
const DB_NAME = 'BibleReaderDB'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'chapters'
|
||||
const CACHE_DURATION_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
const MAX_CACHE_SIZE = 50 // keep last 50 chapters
|
||||
|
||||
let db: IDBDatabase | null = null
|
||||
|
||||
export async function initDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
db = request.result
|
||||
resolve(db)
|
||||
}
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const database = (event.target as IDBOpenDBRequest).result
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = database.createObjectStore(STORE_NAME, { keyPath: 'chapterId' })
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function cacheChapter(chapter: BibleChapter): Promise<void> {
|
||||
if (!db) await initDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry: CacheEntry = {
|
||||
chapterId: chapter.id,
|
||||
data: chapter,
|
||||
timestamp: Date.now(),
|
||||
expiresAt: Date.now() + CACHE_DURATION_MS
|
||||
}
|
||||
|
||||
const transaction = db!.transaction([STORE_NAME], 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
|
||||
// First, check if we need to delete oldest entry
|
||||
const countRequest = store.count()
|
||||
countRequest.onsuccess = () => {
|
||||
if (countRequest.result >= MAX_CACHE_SIZE) {
|
||||
// Delete oldest entry
|
||||
const index = store.index('timestamp')
|
||||
const deleteRequest = index.openCursor()
|
||||
let deleted = false
|
||||
|
||||
deleteRequest.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result
|
||||
if (cursor && !deleted) {
|
||||
cursor.delete()
|
||||
deleted = true
|
||||
// Continue with adding new entry after delete
|
||||
const putRequest = store.put(entry)
|
||||
putRequest.onerror = () => reject(putRequest.error)
|
||||
putRequest.onsuccess = () => resolve()
|
||||
}
|
||||
}
|
||||
deleteRequest.onerror = () => reject(deleteRequest.error)
|
||||
} else {
|
||||
// Just add the entry
|
||||
const putRequest = store.put(entry)
|
||||
putRequest.onerror = () => reject(putRequest.error)
|
||||
putRequest.onsuccess = () => resolve()
|
||||
}
|
||||
}
|
||||
|
||||
countRequest.onerror = () => reject(countRequest.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCachedChapter(chapterId: string): Promise<BibleChapter | null> {
|
||||
if (!db) await initDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db!.transaction([STORE_NAME], 'readonly')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.get(chapterId)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
const entry = request.result as CacheEntry | undefined
|
||||
if (entry && entry.expiresAt > Date.now()) {
|
||||
resolve(entry.data)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearExpiredCache(): Promise<void> {
|
||||
if (!db) await initDatabase()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db!.transaction([STORE_NAME], 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.openCursor()
|
||||
const now = Date.now()
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result
|
||||
if (cursor) {
|
||||
const entry = cursor.value as CacheEntry
|
||||
if (entry.expiresAt < now) {
|
||||
cursor.delete()
|
||||
}
|
||||
cursor.continue()
|
||||
} else {
|
||||
// Cursor is done, resolve
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
109
lib/reading-preferences.ts
Normal file
109
lib/reading-preferences.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ReadingPreference } from '@/types'
|
||||
|
||||
const PRESETS: Record<string, ReadingPreference> = {
|
||||
default: {
|
||||
fontFamily: 'georgia',
|
||||
fontSize: 18,
|
||||
lineHeight: 1.8,
|
||||
letterSpacing: 0,
|
||||
textAlign: 'left',
|
||||
backgroundColor: '#faf8f3',
|
||||
textColor: '#333333',
|
||||
margin: 'normal',
|
||||
preset: 'default'
|
||||
},
|
||||
dyslexia: {
|
||||
fontFamily: 'atkinson',
|
||||
fontSize: 18,
|
||||
lineHeight: 1.9,
|
||||
letterSpacing: 0.08,
|
||||
textAlign: 'left',
|
||||
backgroundColor: '#f5f5dc',
|
||||
textColor: '#333333',
|
||||
margin: 'normal',
|
||||
preset: 'dyslexia'
|
||||
},
|
||||
highContrast: {
|
||||
fontFamily: 'inter',
|
||||
fontSize: 16,
|
||||
lineHeight: 1.6,
|
||||
letterSpacing: 0,
|
||||
textAlign: 'left',
|
||||
backgroundColor: '#000000',
|
||||
textColor: '#ffffff',
|
||||
margin: 'wide',
|
||||
preset: 'highContrast'
|
||||
},
|
||||
minimal: {
|
||||
fontFamily: 'georgia',
|
||||
fontSize: 16,
|
||||
lineHeight: 1.6,
|
||||
letterSpacing: 0,
|
||||
textAlign: 'left',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#000000',
|
||||
margin: 'narrow',
|
||||
preset: 'minimal'
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'bibleReaderPreferences'
|
||||
|
||||
export function getPreset(name: keyof typeof PRESETS): ReadingPreference {
|
||||
return PRESETS[name]
|
||||
}
|
||||
|
||||
export function loadPreferences(): ReadingPreference {
|
||||
if (typeof window === 'undefined') {
|
||||
return PRESETS.default
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return stored ? JSON.parse(stored) : PRESETS.default
|
||||
} catch {
|
||||
return PRESETS.default
|
||||
}
|
||||
}
|
||||
|
||||
export function savePreferences(prefs: ReadingPreference): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
||||
} catch (e) {
|
||||
console.error('Failed to save preferences:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCSSVariables(prefs: ReadingPreference): Record<string, string> {
|
||||
return {
|
||||
'--font-family': getFontStack(prefs.fontFamily),
|
||||
'--font-size': `${prefs.fontSize}px`,
|
||||
'--line-height': `${prefs.lineHeight}`,
|
||||
'--letter-spacing': `${prefs.letterSpacing}em`,
|
||||
'--text-align': prefs.textAlign,
|
||||
'--bg-color': prefs.backgroundColor,
|
||||
'--text-color': prefs.textColor,
|
||||
'--margin-width': getMarginWidth(prefs.margin),
|
||||
}
|
||||
}
|
||||
|
||||
function getFontStack(fontFamily: string): string {
|
||||
const stacks: Record<string, string> = {
|
||||
georgia: 'Georgia, serif',
|
||||
inter: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
atkinson: '"Atkinson Hyperlegible", sans-serif',
|
||||
merriweather: '"Merriweather", serif',
|
||||
}
|
||||
return stacks[fontFamily] || stacks.georgia
|
||||
}
|
||||
|
||||
function getMarginWidth(margin: string): string {
|
||||
const margins: Record<string, string> = {
|
||||
narrow: 'max(1rem, 5%)',
|
||||
normal: 'max(2rem, 10%)',
|
||||
wide: 'max(4rem, 15%)',
|
||||
}
|
||||
return margins[margin] || margins.normal
|
||||
}
|
||||
@@ -7,6 +7,5 @@ if (!process.env.STRIPE_SECRET_KEY) {
|
||||
// Initialize Stripe on the server side ONLY
|
||||
// This file should NEVER be imported in client-side code
|
||||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2025-09-30.clover',
|
||||
typescript: true,
|
||||
})
|
||||
|
||||
2766
package-lock.json
generated
2766
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,10 @@
|
||||
"@mui/x-data-grid": "^8.11.3",
|
||||
"@mui/x-date-pickers": "^8.11.3",
|
||||
"@next/font": "^14.2.15",
|
||||
"@payloadcms/db-postgres": "^3.62.1",
|
||||
"@payloadcms/next": "^3.62.1",
|
||||
"@payloadcms/plugin-stripe": "^3.62.1",
|
||||
"@payloadcms/richtext-lexical": "^3.62.1",
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -70,6 +74,7 @@
|
||||
"next-intl": "^4.3.9",
|
||||
"nodemailer": "^7.0.9",
|
||||
"openai": "^5.22.0",
|
||||
"payload": "^3.62.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pg": "^8.16.3",
|
||||
"pgvector": "^0.2.1",
|
||||
@@ -83,7 +88,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stripe": "^19.1.0",
|
||||
"stripe": "^19.2.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tinymce": "^8.1.2",
|
||||
|
||||
129
payload.config.ts
Normal file
129
payload.config.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import path from 'path';
|
||||
import { buildConfig } from 'payload';
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
import { stripePlugin } from '@payloadcms/plugin-stripe';
|
||||
|
||||
import { Users } from './payload/collections/Users';
|
||||
import { Products } from './payload/collections/Products';
|
||||
import { Prices } from './payload/collections/Prices';
|
||||
import { Subscriptions } from './payload/collections/Subscriptions';
|
||||
import { Customers } from './payload/collections/Customers';
|
||||
import { BibleBooks } from './payload/collections/BibleBooks';
|
||||
import { BibleVerses } from './payload/collections/BibleVerses';
|
||||
import { Bookmarks } from './payload/collections/Bookmarks';
|
||||
import { Highlights } from './payload/collections/Highlights';
|
||||
import { Donations } from './payload/collections/Donations';
|
||||
import { CheckoutSessions } from './payload/collections/CheckoutSessions';
|
||||
import { FailedPayments } from './payload/collections/FailedPayments';
|
||||
|
||||
import { SiteSettings } from './payload/globals/SiteSettings';
|
||||
|
||||
export default buildConfig({
|
||||
secret: process.env.PAYLOAD_SECRET || 'development-secret',
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
livePreview: {
|
||||
breakpoints: [
|
||||
{
|
||||
label: 'Mobile',
|
||||
name: 'mobile',
|
||||
width: 375,
|
||||
height: 667,
|
||||
},
|
||||
{
|
||||
label: 'Tablet',
|
||||
name: 'tablet',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
},
|
||||
{
|
||||
label: 'Desktop',
|
||||
name: 'desktop',
|
||||
width: 1440,
|
||||
height: 900,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
titleSuffix: '- Biblical Guide',
|
||||
},
|
||||
},
|
||||
editor: lexicalEditor(),
|
||||
collections: [
|
||||
Users,
|
||||
Customers,
|
||||
Subscriptions,
|
||||
Products,
|
||||
Prices,
|
||||
BibleBooks,
|
||||
BibleVerses,
|
||||
Bookmarks,
|
||||
Highlights,
|
||||
Donations,
|
||||
CheckoutSessions,
|
||||
FailedPayments,
|
||||
],
|
||||
globals: [SiteSettings],
|
||||
plugins: [
|
||||
stripePlugin({
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
stripeWebhooksEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
webhooks: {
|
||||
'checkout.session.completed': async ({ event, payload: payloadInstance }) => {
|
||||
console.log('Stripe webhook: checkout.session.completed', event.id);
|
||||
// Webhook handling will be in separate file
|
||||
},
|
||||
'customer.subscription.created': async ({ event }) => {
|
||||
console.log('Stripe webhook: customer.subscription.created', event.id);
|
||||
},
|
||||
'customer.subscription.updated': async ({ event }) => {
|
||||
console.log('Stripe webhook: customer.subscription.updated', event.id);
|
||||
},
|
||||
'customer.subscription.deleted': async ({ event }) => {
|
||||
console.log('Stripe webhook: customer.subscription.deleted', event.id);
|
||||
},
|
||||
},
|
||||
sync: [],
|
||||
}),
|
||||
],
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
},
|
||||
}),
|
||||
typescript: {
|
||||
outputFile: path.resolve(__dirname, 'types/payload-types.ts'),
|
||||
},
|
||||
routes: {
|
||||
admin: '/admin/payload',
|
||||
api: '/api/payload',
|
||||
},
|
||||
localization: {
|
||||
locales: ['en', 'ro', 'es', 'it'],
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
},
|
||||
upload: {
|
||||
limits: {
|
||||
fileSize: 5000000, // 5MB
|
||||
},
|
||||
},
|
||||
onInit: async (payload) => {
|
||||
console.log('Payload initialized');
|
||||
// Check if we need to run migrations
|
||||
const adminUsers = await payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
role: {
|
||||
equals: 'admin',
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (adminUsers.totalDocs === 0) {
|
||||
console.log('No admin user found. Please create one via the admin panel.');
|
||||
}
|
||||
},
|
||||
});
|
||||
63
payload/collections/BibleBooks.ts
Normal file
63
payload/collections/BibleBooks.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const BibleBooks: CollectionConfig = {
|
||||
slug: 'bible-books',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'abbreviation', 'testament', 'chapterCount', 'order'],
|
||||
group: 'Bible Content',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'bookId',
|
||||
type: 'number',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'abbreviation',
|
||||
type: 'text',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'testament',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Old Testament', value: 'OT' },
|
||||
{ label: 'New Testament', value: 'NT' },
|
||||
],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'chapterCount',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
type: 'number',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Display order in Bible',
|
||||
},
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
94
payload/collections/BibleVerses.ts
Normal file
94
payload/collections/BibleVerses.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const BibleVerses: CollectionConfig = {
|
||||
slug: 'bible-verses',
|
||||
admin: {
|
||||
useAsTitle: 'reference',
|
||||
defaultColumns: ['reference', 'version', 'book', 'chapter'],
|
||||
group: 'Bible Content',
|
||||
pagination: {
|
||||
defaultLimit: 50,
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'book',
|
||||
type: 'relationship',
|
||||
relationTo: 'bible-books',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'chapter',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 1,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'verse',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 1,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'version',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Cornilescu (VDC)', value: 'VDC' },
|
||||
{ label: 'NASB', value: 'NASB' },
|
||||
{ label: 'RVR', value: 'RVR' },
|
||||
{ label: 'NR', value: 'NR' },
|
||||
],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'reference',
|
||||
type: 'text',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ data, siblingData, req }) => {
|
||||
if (!data) return '';
|
||||
|
||||
if (siblingData?.book && data.chapter && data.verse) {
|
||||
const book = await req.payload.findByID({
|
||||
collection: 'bible-books',
|
||||
id: siblingData.book,
|
||||
});
|
||||
|
||||
if (book) {
|
||||
return `${book.name} ${data.chapter}:${data.verse}`;
|
||||
}
|
||||
}
|
||||
return data.reference || '';
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'embedding',
|
||||
type: 'json',
|
||||
admin: {
|
||||
hidden: true,
|
||||
description: 'Vector embedding for semantic search',
|
||||
},
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
60
payload/collections/Bookmarks.ts
Normal file
60
payload/collections/Bookmarks.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Bookmarks: CollectionConfig = {
|
||||
slug: 'bookmarks',
|
||||
admin: {
|
||||
useAsTitle: 'id',
|
||||
defaultColumns: ['user', 'book', 'chapter', 'createdAt'],
|
||||
group: 'User Content',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'book',
|
||||
type: 'relationship',
|
||||
relationTo: 'bible-books',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'chapter',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
name: 'verse',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => !!req.user,
|
||||
update: ({ req }) => !!req.user,
|
||||
delete: ({ req }) => !!req.user,
|
||||
},
|
||||
};
|
||||
85
payload/collections/CheckoutSessions.ts
Normal file
85
payload/collections/CheckoutSessions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const CheckoutSessions: CollectionConfig = {
|
||||
slug: 'checkout-sessions',
|
||||
admin: {
|
||||
useAsTitle: 'sessionId',
|
||||
defaultColumns: ['sessionId', 'user', 'type', 'status', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
pagination: {
|
||||
defaultLimit: 100,
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'sessionId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
type: 'relationship',
|
||||
relationTo: 'prices',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Subscription', value: 'subscription' },
|
||||
{ label: 'Donation', value: 'donation' },
|
||||
{ label: 'One-time Purchase', value: 'one-time' },
|
||||
],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Expired', value: 'expired' },
|
||||
{ label: 'Failed', value: 'failed' },
|
||||
],
|
||||
defaultValue: 'pending',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
80
payload/collections/Customers.ts
Normal file
80
payload/collections/Customers.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Customers: CollectionConfig = {
|
||||
slug: 'customers',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'name', 'stripeCustomerId', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stripeCustomerId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
hasMany: false,
|
||||
unique: true,
|
||||
admin: {
|
||||
description: 'Associated user account',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Custom Stripe metadata',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Users can only read their own customer record
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => {
|
||||
return req.user?.role === 'admin' || req.user?.role === 'super-admin';
|
||||
},
|
||||
update: ({ req }) => {
|
||||
return req.user?.role === 'admin' || req.user?.role === 'super-admin';
|
||||
},
|
||||
delete: ({ req }) => {
|
||||
return req.user?.role === 'super-admin';
|
||||
},
|
||||
},
|
||||
};
|
||||
74
payload/collections/Donations.ts
Normal file
74
payload/collections/Donations.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Donations: CollectionConfig = {
|
||||
slug: 'donations',
|
||||
admin: {
|
||||
useAsTitle: 'donorName',
|
||||
defaultColumns: ['donorName', 'amount', 'currency', 'status', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'donorName',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'donorEmail',
|
||||
type: 'email',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Amount in dollars',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
defaultValue: 'USD',
|
||||
},
|
||||
{
|
||||
name: 'stripeSessionId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'stripePaymentIntentId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Failed', value: 'failed' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'anonymous',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
create: () => true,
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
78
payload/collections/FailedPayments.ts
Normal file
78
payload/collections/FailedPayments.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const FailedPayments: CollectionConfig = {
|
||||
slug: 'failed-payments',
|
||||
admin: {
|
||||
useAsTitle: 'id',
|
||||
defaultColumns: ['stripePaymentIntentId', 'amount', 'errorCode', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stripePaymentIntentId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customerId',
|
||||
type: 'text',
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Amount in cents',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'errorCode',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'retryCount',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'resolved',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'resolvedAt',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Internal notes about this failure',
|
||||
},
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
62
payload/collections/Highlights.ts
Normal file
62
payload/collections/Highlights.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Highlights: CollectionConfig = {
|
||||
slug: 'highlights',
|
||||
admin: {
|
||||
useAsTitle: 'id',
|
||||
defaultColumns: ['user', 'color', 'createdAt'],
|
||||
group: 'User Content',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'verse',
|
||||
type: 'relationship',
|
||||
relationTo: 'bible-verses',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Yellow', value: 'yellow' },
|
||||
{ label: 'Green', value: 'green' },
|
||||
{ label: 'Blue', value: 'blue' },
|
||||
{ label: 'Red', value: 'red' },
|
||||
{ label: 'Pink', value: 'pink' },
|
||||
],
|
||||
defaultValue: 'yellow',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => !!req.user,
|
||||
update: ({ req }) => !!req.user,
|
||||
delete: ({ req }) => !!req.user,
|
||||
},
|
||||
};
|
||||
122
payload/collections/Prices.ts
Normal file
122
payload/collections/Prices.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Prices: CollectionConfig = {
|
||||
slug: 'prices',
|
||||
admin: {
|
||||
useAsTitle: 'displayName',
|
||||
defaultColumns: ['displayName', 'stripePriceId', 'unitAmount', 'currency', 'active'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'displayName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data, siblingData }) => {
|
||||
const amount = ((siblingData.unitAmount || 0) / 100).toFixed(2);
|
||||
const currency = (siblingData.currency || 'USD').toUpperCase();
|
||||
const interval = siblingData.recurring?.interval;
|
||||
|
||||
if (interval) {
|
||||
const intervalCount = siblingData.recurring?.intervalCount || 1;
|
||||
const label = intervalCount > 1 ? `${intervalCount} ${interval}s` : interval;
|
||||
return `${currency} ${amount}/${label}`;
|
||||
}
|
||||
return `${currency} ${amount}`;
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'product',
|
||||
type: 'relationship',
|
||||
relationTo: 'products',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'stripePriceId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'unitAmount',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Amount in cents (e.g., 9999 = $99.99)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'USD ($)', value: 'usd' },
|
||||
{ label: 'EUR (€)', value: 'eur' },
|
||||
{ label: 'GBP (£)', value: 'gbp' },
|
||||
{ label: 'RON (lei)', value: 'ron' },
|
||||
],
|
||||
defaultValue: 'usd',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'recurring',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Leave empty for one-time prices',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'interval',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Daily', value: 'day' },
|
||||
{ label: 'Weekly', value: 'week' },
|
||||
{ label: 'Monthly', value: 'month' },
|
||||
{ label: 'Yearly', value: 'year' },
|
||||
],
|
||||
required: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => !!siblingData?.interval !== false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'intervalCount',
|
||||
type: 'number',
|
||||
defaultValue: 1,
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'trialPeriodDays',
|
||||
type: 'number',
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Number of trial days (optional)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: () => true, // Prices are public
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
102
payload/collections/Products.ts
Normal file
102
payload/collections/Products.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Products: CollectionConfig = {
|
||||
slug: 'products',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'stripeProductId', 'active', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'richText',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'stripeProductId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'planType',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Free', value: 'free' },
|
||||
{ label: 'Premium', value: 'premium' },
|
||||
{ label: 'Enterprise', value: 'enterprise' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'features',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'feature',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'included',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
admin: {
|
||||
condition: (data, siblingData) => !siblingData.included,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'prices',
|
||||
type: 'relationship',
|
||||
relationTo: 'prices',
|
||||
hasMany: true,
|
||||
admin: {
|
||||
description: 'Associated price points for this product',
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ data, operation }) => {
|
||||
if (operation === 'create' && !data.stripeProductId) {
|
||||
console.log('Product created:', data.name, '- Stripe ID should be synced from Stripe plugin');
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
},
|
||||
access: {
|
||||
read: () => true, // Products are public
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
155
payload/collections/Subscriptions.ts
Normal file
155
payload/collections/Subscriptions.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Subscriptions: CollectionConfig = {
|
||||
slug: 'subscriptions',
|
||||
admin: {
|
||||
useAsTitle: 'id',
|
||||
defaultColumns: ['customer', 'status', 'currentPeriodEnd', 'active'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stripeSubscriptionId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'relationship',
|
||||
relationTo: 'customers',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'prices',
|
||||
type: 'relationship',
|
||||
relationTo: 'prices',
|
||||
hasMany: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Past Due', value: 'past_due' },
|
||||
{ label: 'Canceled', value: 'canceled' },
|
||||
{ label: 'Incomplete', value: 'incomplete' },
|
||||
{ label: 'Incomplete Expired', value: 'incomplete_expired' },
|
||||
{ label: 'Trialing', value: 'trialing' },
|
||||
{ label: 'Unpaid', value: 'unpaid' },
|
||||
{ label: 'Paused', value: 'paused' },
|
||||
],
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currentPeriodStart',
|
||||
type: 'date',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currentPeriodEnd',
|
||||
type: 'date',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'canceledAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cancelAtPeriodEnd',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'planName',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'conversationCount',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
admin: {
|
||||
description: 'Monthly conversation count for free tier',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastResetDate',
|
||||
type: 'date',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, operation, req }) => {
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
// Update user's subscription reference
|
||||
if (doc.user) {
|
||||
await req.payload.update({
|
||||
collection: 'users',
|
||||
id: doc.user,
|
||||
data: {
|
||||
subscription: doc.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: () => false, // Never delete subscription records
|
||||
},
|
||||
};
|
||||
207
payload/collections/Users.ts
Normal file
207
payload/collections/Users.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: {
|
||||
tokenExpiration: 604800, // 7 days
|
||||
cookies: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'name', 'role', 'createdAt'],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: false,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
label: 'User',
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
},
|
||||
{
|
||||
label: 'Super Admin',
|
||||
value: 'super-admin',
|
||||
},
|
||||
],
|
||||
defaultValue: 'user',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'favoriteVersion',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Cornilescu', value: 'VDC' },
|
||||
{ label: 'NASB', value: 'NASB' },
|
||||
{ label: 'RVR', value: 'RVR' },
|
||||
{ label: 'NR', value: 'NR' },
|
||||
],
|
||||
defaultValue: 'VDC',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stripeCustomerId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
description: 'Automatically set by Stripe integration',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'subscription',
|
||||
type: 'relationship',
|
||||
relationTo: 'subscriptions',
|
||||
hasMany: false,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'profileSettings',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'fontSize',
|
||||
type: 'number',
|
||||
defaultValue: 16,
|
||||
min: 12,
|
||||
max: 24,
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
],
|
||||
defaultValue: 'light',
|
||||
},
|
||||
{
|
||||
name: 'showVerseNumbers',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: 'enableNotifications',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
label: 'Profile Settings',
|
||||
},
|
||||
{
|
||||
name: 'activityLog',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'timestamp',
|
||||
type: 'date',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Automatically tracked user activities',
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ data, operation }) => {
|
||||
if (operation === 'create' && !data.email) {
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
afterChange: [
|
||||
async ({ doc, operation }) => {
|
||||
if (operation === 'create') {
|
||||
console.log(`New user created: ${doc.email}`);
|
||||
}
|
||||
return doc;
|
||||
},
|
||||
],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
// Users can read their own data, admins can read all
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
id: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: () => {
|
||||
// Public can create accounts (registration)
|
||||
return true;
|
||||
},
|
||||
update: ({ req }) => {
|
||||
// Users can update their own data, admins can update all
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
id: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
delete: ({ req }) => {
|
||||
// Only super admins can delete users
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return req.user.role === 'super-admin';
|
||||
},
|
||||
},
|
||||
};
|
||||
12
payload/collections/index.ts
Normal file
12
payload/collections/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { Users } from './Users';
|
||||
export { Customers } from './Customers';
|
||||
export { Subscriptions } from './Subscriptions';
|
||||
export { Products } from './Products';
|
||||
export { Prices } from './Prices';
|
||||
export { BibleBooks } from './BibleBooks';
|
||||
export { BibleVerses } from './BibleVerses';
|
||||
export { Bookmarks } from './Bookmarks';
|
||||
export { Highlights } from './Highlights';
|
||||
export { Donations } from './Donations';
|
||||
export { CheckoutSessions } from './CheckoutSessions';
|
||||
export { FailedPayments } from './FailedPayments';
|
||||
141
payload/globals/SiteSettings.ts
Normal file
141
payload/globals/SiteSettings.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { GlobalConfig } from 'payload';
|
||||
|
||||
export const SiteSettings: GlobalConfig = {
|
||||
slug: 'site-settings',
|
||||
admin: {
|
||||
group: 'Configuration',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'siteName',
|
||||
type: 'text',
|
||||
required: true,
|
||||
defaultValue: 'Biblical Guide',
|
||||
},
|
||||
{
|
||||
name: 'siteDescription',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'siteUrl',
|
||||
type: 'text',
|
||||
required: true,
|
||||
defaultValue: 'https://biblical-guide.com',
|
||||
},
|
||||
{
|
||||
name: 'contactEmail',
|
||||
type: 'email',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'paymentSettings',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'stripePublishableKey',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Public Stripe key for frontend',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'enableDonations',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: 'minimumDonation',
|
||||
type: 'number',
|
||||
defaultValue: 1,
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Minimum donation amount in dollars',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'emailSettings',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'fromEmail',
|
||||
type: 'email',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Email address for transactional emails',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fromName',
|
||||
type: 'text',
|
||||
defaultValue: 'Biblical Guide',
|
||||
},
|
||||
{
|
||||
name: 'adminEmail',
|
||||
type: 'email',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Admin notification email',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'socialMedia',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'facebook',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Facebook URL',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'twitter',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Twitter/X URL',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'instagram',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Instagram URL',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'youtube',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'YouTube channel URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'maintenanceMode',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Enable to put the site in maintenance mode',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'maintenanceMessage',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
condition: (data) => data?.maintenanceMode === true,
|
||||
description: 'Message to display during maintenance',
|
||||
},
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -54,3 +54,43 @@ export interface PrayerRequest {
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// Bible Reader 2025 Types
|
||||
export interface BibleChapter {
|
||||
id: string
|
||||
bookId: number
|
||||
bookName: string
|
||||
chapter: number
|
||||
verses: BibleVerse[]
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export interface ReadingPreference {
|
||||
fontFamily: string // 'georgia', 'inter', 'atkinson', etc.
|
||||
fontSize: number // 12-32
|
||||
lineHeight: number // 1.4-2.2
|
||||
letterSpacing: number // 0-0.15
|
||||
textAlign: 'left' | 'center' | 'justify'
|
||||
backgroundColor: string // color code
|
||||
textColor: string // color code
|
||||
margin: 'narrow' | 'normal' | 'wide'
|
||||
preset: 'default' | 'dyslexia' | 'highContrast' | 'minimal' | 'custom'
|
||||
}
|
||||
|
||||
export interface UserAnnotation {
|
||||
id: string
|
||||
verseId: string
|
||||
chapterId: string
|
||||
type: 'bookmark' | 'highlight' | 'note' | 'crossRef'
|
||||
content?: string
|
||||
color?: string // for highlights
|
||||
timestamp: number
|
||||
synced: boolean
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
chapterId: string
|
||||
data: BibleChapter
|
||||
timestamp: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user