Compare commits
14 Commits
c36710d56c
...
9b5c0ed8bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
# Reduce bundle analysis during builds
|
||||||
DISABLE_ESLINT_PLUGIN=true
|
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
|
# Authentication
|
||||||
NEXTAUTH_URL=https://biblical-guide.com
|
NEXTAUTH_URL=https://biblical-guide.com
|
||||||
NEXTAUTH_SECRET=development-secret-change-in-production
|
NEXTAUTH_SECRET=development-secret-change-in-production
|
||||||
@@ -38,6 +44,7 @@ STRIPE_SECRET_KEY=sk_live_51GtAFuJN43EN3sSfcAVuTR5S3cZrgIl6wO4zQfVm7B0El8WLdsBbu
|
|||||||
STRIPE_PUBLISHABLE_KEY=pk_live_H0pO0dWQR0QDqLybpwlR4nDl00UhzqVGnO
|
STRIPE_PUBLISHABLE_KEY=pk_live_H0pO0dWQR0QDqLybpwlR4nDl00UhzqVGnO
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_9kVqP17aLh0fnU7oA7UApe2c4hKkXDYL
|
STRIPE_WEBHOOK_SECRET=whsec_9kVqP17aLh0fnU7oA7UApe2c4hKkXDYL
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_H0pO0dWQR0QDqLybpwlR4nDl00UhzqVGnO
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_H0pO0dWQR0QDqLybpwlR4nDl00UhzqVGnO
|
||||||
|
STRIPE_PREMIUM_PRODUCT_ID=prod_TE9c0qCn4TMgU8
|
||||||
STRIPE_PREMIUM_MONTHLY_PRICE_ID=price_1SHhJDJN43EN3sSfzJ883lHA
|
STRIPE_PREMIUM_MONTHLY_PRICE_ID=price_1SHhJDJN43EN3sSfzJ883lHA
|
||||||
STRIPE_PREMIUM_YEARLY_PRICE_ID=price_1SHhKEJN43EN3sSfXYyYStNS
|
STRIPE_PREMIUM_YEARLY_PRICE_ID=price_1SHhKEJN43EN3sSfXYyYStNS
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_MONTHLY_PRICE_ID=price_1SHhJDJN43EN3sSfzJ883lHA
|
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 { BibleReaderApp } from '@/components/bible/bible-reader-app'
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
import BibleReader from './reader'
|
|
||||||
import { prisma } from '@/lib/db'
|
|
||||||
|
|
||||||
interface PageProps {
|
export const metadata = {
|
||||||
searchParams: Promise<{
|
title: 'Read Bible',
|
||||||
version?: string
|
description: 'Modern Bible reader with offline support'
|
||||||
book?: string
|
|
||||||
chapter?: string
|
|
||||||
verse?: string
|
|
||||||
}>
|
|
||||||
params: Promise<{
|
|
||||||
locale: string
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to convert UUIDs to SEO-friendly slugs
|
export default function BiblePage() {
|
||||||
async function convertToSeoUrl(versionId: string, bookId: string, chapter: string, locale: string) {
|
return <BibleReaderApp />
|
||||||
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 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 { useState, useEffect } from 'react'
|
||||||
import { BibleReader } from '@/components/bible/reader'
|
import { BibleReader } from '@/components/bible/reader'
|
||||||
import { ChatInterface } from '@/components/chat/chat-interface'
|
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() {
|
export default function Dashboard() {
|
||||||
const [activeTab, setActiveTab] = useState('bible')
|
const [activeTab, setActiveTab] = useState('bible')
|
||||||
@@ -41,8 +42,9 @@ export default function Dashboard() {
|
|||||||
return <BibleReader />
|
return <BibleReader />
|
||||||
case 'chat':
|
case 'chat':
|
||||||
return <ChatInterface />
|
return <ChatInterface />
|
||||||
case 'prayers':
|
// DISABLED: Prayer Wall Feature
|
||||||
return <PrayerWall />
|
// case 'prayers':
|
||||||
|
// return <PrayerWall />
|
||||||
case 'search':
|
case 'search':
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
@@ -76,7 +78,8 @@ export default function Dashboard() {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'bible', label: 'Citește Biblia' },
|
{ id: 'bible', label: 'Citește Biblia' },
|
||||||
{ id: 'chat', label: 'Chat AI' },
|
{ id: 'chat', label: 'Chat AI' },
|
||||||
{ id: 'prayers', label: 'Rugăciuni' },
|
// DISABLED: Prayer Wall Feature
|
||||||
|
// { id: 'prayers', label: 'Rugăciuni' },
|
||||||
{ id: 'search', label: 'Căutare' },
|
{ id: 'search', label: 'Căutare' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -127,13 +127,14 @@ export default function Home() {
|
|||||||
path: '/__open-chat__',
|
path: '/__open-chat__',
|
||||||
color: theme.palette.secondary.main,
|
color: theme.palette.secondary.main,
|
||||||
},
|
},
|
||||||
{
|
// DISABLED: Prayer Wall Feature
|
||||||
title: t('features.prayers.title'),
|
// {
|
||||||
description: t('features.prayers.description'),
|
// title: t('features.prayers.title'),
|
||||||
icon: <Prayer sx={{ fontSize: 40, color: 'success.main' }} />,
|
// description: t('features.prayers.description'),
|
||||||
path: '/prayers',
|
// icon: <Prayer sx={{ fontSize: 40, color: 'success.main' }} />,
|
||||||
color: theme.palette.success.main,
|
// path: '/prayers',
|
||||||
},
|
// color: theme.palette.success.main,
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
title: t('features.search.title'),
|
title: t('features.search.title'),
|
||||||
description: t('features.search.description'),
|
description: t('features.search.description'),
|
||||||
@@ -372,8 +373,8 @@ export default function Home() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{/* Community Prayer Wall */}
|
{/* DISABLED: Community Prayer Wall */}
|
||||||
<Paper sx={{ bgcolor: 'background.paper', py: 6, mb: 8 }}>
|
{/* <Paper sx={{ bgcolor: 'background.paper', py: 6, mb: 8 }}>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Typography variant="h3" component="h2" textAlign="center" sx={{ mb: 6 }}>
|
<Typography variant="h3" component="h2" textAlign="center" sx={{ mb: 6 }}>
|
||||||
{t('prayerWall.title')}
|
{t('prayerWall.title')}
|
||||||
@@ -415,7 +416,7 @@ export default function Home() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</Paper>
|
</Paper> */}
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<Container maxWidth="lg" sx={{ mb: 8 }}>
|
<Container maxWidth="lg" sx={{ mb: 8 }}>
|
||||||
|
|||||||
@@ -1,807 +1,10 @@
|
|||||||
'use client'
|
// DISABLED: Prayer Wall Feature
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PrayersPage() {
|
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 (
|
return (
|
||||||
<Box>
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<h1>Prayer Wall Feature Disabled</h1>
|
||||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
<p>This feature is currently disabled.</p>
|
||||||
{/* Header */}
|
</div>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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: 'Email Settings', icon: EmailIcon, href: '/admin/mailgun' },
|
||||||
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
||||||
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
{ 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' },
|
{ text: 'Settings', icon: Settings, href: '/admin/settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
251
components/bible/bible-reader-app.tsx
Normal file
251
components/bible/bible-reader-app.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
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 [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
|
||||||
|
useEffect(() => {
|
||||||
|
loadBooks()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
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: '100vh', overflow: 'hidden' }}>
|
||||||
|
{/* Header with search */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchNavigator
|
||||||
|
onNavigate={(newBookId, newChapter) => {
|
||||||
|
setBookId(newBookId)
|
||||||
|
setChapter(newChapter)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Reading area */}
|
||||||
|
<Box sx={{ flex: 1, mt: '80px', 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 = [
|
const basePages = [
|
||||||
{ name: t('home'), path: '/', icon: <Home /> },
|
{ name: t('home'), path: '/', icon: <Home /> },
|
||||||
{ name: t('bible'), path: '/bible', icon: <MenuBook /> },
|
{ 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 /> },
|
{ 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 { useEffect, useState } from 'react'
|
||||||
import { Heart, Send } from 'lucide-react'
|
import { Heart, Send } from 'lucide-react'
|
||||||
@@ -185,4 +186,13 @@ export function PrayerWall() {
|
|||||||
</div>
|
</div>
|
||||||
</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 = [
|
const navItems = [
|
||||||
{ id: 'bible', label: 'Biblia', icon: Book },
|
{ id: 'bible', label: 'Biblia', icon: Book },
|
||||||
{ id: 'chat', label: 'Chat AI', icon: MessageCircle },
|
// { id: 'chat', label: 'Chat AI', icon: MessageCircle }, // AI Chat disabled
|
||||||
{ id: 'prayers', label: 'Rugăciuni', icon: Heart },
|
// DISABLED: Prayer Wall Feature
|
||||||
|
// { id: 'prayers', label: 'Rugăciuni', icon: Heart },
|
||||||
{ id: 'search', label: 'Căutare', icon: Search },
|
{ 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
|
// Initialize Stripe on the server side ONLY
|
||||||
// This file should NEVER be imported in client-side code
|
// This file should NEVER be imported in client-side code
|
||||||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
apiVersion: '2025-09-30.clover',
|
|
||||||
typescript: true,
|
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-data-grid": "^8.11.3",
|
||||||
"@mui/x-date-pickers": "^8.11.3",
|
"@mui/x-date-pickers": "^8.11.3",
|
||||||
"@next/font": "^14.2.15",
|
"@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",
|
"@prisma/client": "^6.16.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -70,6 +74,7 @@
|
|||||||
"next-intl": "^4.3.9",
|
"next-intl": "^4.3.9",
|
||||||
"nodemailer": "^7.0.9",
|
"nodemailer": "^7.0.9",
|
||||||
"openai": "^5.22.0",
|
"openai": "^5.22.0",
|
||||||
|
"payload": "^3.62.1",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pgvector": "^0.2.1",
|
"pgvector": "^0.2.1",
|
||||||
@@ -83,7 +88,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"stripe": "^19.1.0",
|
"stripe": "^19.2.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tinymce": "^8.1.2",
|
"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
|
createdAt: Date
|
||||||
updatedAt: 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