Add complete Biblical Guide web application with Material UI

Implemented comprehensive Romanian Biblical Guide web app:
- Next.js 15 with App Router and TypeScript
- Material UI 7.3.2 for modern, responsive design
- PostgreSQL database with Prisma ORM
- Complete Bible reader with book/chapter navigation
- AI-powered biblical chat with Romanian responses
- Prayer wall for community prayer requests
- Advanced Bible search with filters and highlighting
- Sample Bible data imported from API.Bible
- All API endpoints created and working
- Professional Material UI components throughout
- Responsive layout with navigation and theme

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
andupetcu
2025-09-20 14:10:28 +03:00
commit 3b375c869b
70 changed files with 20406 additions and 0 deletions

59
lib/ai/azure-openai.ts Normal file
View File

@@ -0,0 +1,59 @@
import { AzureOpenAI } from 'openai'
const client = new AzureOpenAI({
apiKey: process.env.AZURE_OPENAI_KEY!,
apiVersion: '2024-02-01',
endpoint: process.env.AZURE_OPENAI_ENDPOINT!,
})
export async function generateChatResponse(
messages: Array<{ role: string; content: string }>,
verseContext?: string
) {
try {
const systemPrompt = `Ești un asistent pentru studiul Bibliei care răspunde în română. Întotdeauna oferă referințe din Scriptură pentru răspunsurile tale. Fii respectuos și oferă răspunsuri biblice fundamentate. ${verseContext ? `Context: ${verseContext}` : ''}`
const response = await client.chat.completions.create({
model: process.env.AZURE_OPENAI_DEPLOYMENT || 'gpt-4',
messages: [
{ role: 'system', content: systemPrompt },
...messages
],
temperature: 0.7,
max_tokens: 1000
})
return response.choices[0].message.content
} catch (error) {
console.error('Azure OpenAI error:', error)
throw new Error('Eroare la generarea răspunsului AI')
}
}
export async function generateEmbedding(text: string): Promise<number[]> {
try {
if (!process.env.OLLAMA_API_URL) {
throw new Error('OLLAMA_API_URL not configured')
}
const response = await fetch(`${process.env.OLLAMA_API_URL}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'nomic-embed-text',
prompt: text
})
})
if (!response.ok) {
throw new Error('Failed to generate embedding')
}
const data = await response.json()
return data.embedding
} catch (error) {
console.error('Embedding generation error:', error)
// Return empty array if embedding service is not available
return []
}
}

44
lib/auth/index.ts Normal file
View File

@@ -0,0 +1,44 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { prisma } from '@/lib/db'
export async function createUser(email: string, password: string, name?: string) {
const passwordHash = await bcrypt.hash(password, 10)
return prisma.user.create({
data: { email, passwordHash, name }
})
}
export async function validateUser(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) return null
const isValid = await bcrypt.compare(password, user.passwordHash)
return isValid ? user : null
}
export function generateToken(userId: string): string {
return jwt.sign({ userId }, process.env.JWT_SECRET!, { expiresIn: '7d' })
}
export async function verifyToken(token: string) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }
return payload
} catch (error) {
throw new Error('Invalid token')
}
}
export async function getUserFromToken(token: string) {
try {
const payload = await verifyToken(token)
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, name: true, theme: true, fontSize: true }
})
return user
} catch (error) {
return null
}
}

71
lib/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,71 @@
import { prisma } from '@/lib/db'
export class CacheManager {
static async get(key: string): Promise<string | null> {
try {
const result = await prisma.$queryRaw<{ value: string }[]>`
SELECT value FROM verse_cache
WHERE key = ${key}
AND expires_at > NOW()
LIMIT 1
`
return result[0]?.value || null
} catch (error) {
console.error('Cache get error:', error)
return null
}
}
static async set(key: string, value: string, ttl: number = 3600): Promise<void> {
try {
const expiresAt = new Date(Date.now() + ttl * 1000)
await prisma.$executeRaw`
INSERT INTO verse_cache (key, value, expires_at)
VALUES (${key}, ${value}, ${expiresAt})
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at
`
} catch (error) {
console.error('Cache set error:', error)
}
}
static async invalidate(pattern: string): Promise<void> {
try {
await prisma.$executeRaw`
DELETE FROM verse_cache WHERE key LIKE ${pattern}
`
} catch (error) {
console.error('Cache invalidate error:', error)
}
}
static async clear(): Promise<void> {
try {
await prisma.$executeRaw`DELETE FROM verse_cache`
} catch (error) {
console.error('Cache clear error:', error)
}
}
static async cleanup(): Promise<void> {
try {
await prisma.$executeRaw`DELETE FROM verse_cache WHERE expires_at < NOW()`
} catch (error) {
console.error('Cache cleanup error:', error)
}
}
// Helper methods for specific cache patterns
static getChapterKey(bookId: number, chapterNum: number): string {
return `chapter:${bookId}:${chapterNum}`
}
static getSearchKey(query: string, limit: number): string {
return `search:${query.toLowerCase()}:${limit}`
}
static getUserBookmarksKey(userId: string): string {
return `bookmarks:${userId}`
}
}

11
lib/db.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

46
lib/store/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { User, Bookmark } from '@/types'
interface AppState {
user: User | null
theme: 'light' | 'dark'
fontSize: 'small' | 'medium' | 'large'
currentBook: number
currentChapter: number
bookmarks: Bookmark[]
setUser: (user: User | null) => void
setTheme: (theme: 'light' | 'dark') => void
setFontSize: (size: 'small' | 'medium' | 'large') => void
setCurrentBook: (book: number) => void
setCurrentChapter: (chapter: number) => void
addBookmark: (bookmark: Bookmark) => void
removeBookmark: (bookmarkId: string) => void
}
export const useStore = create<AppState>()(
persist(
(set) => ({
user: null,
theme: 'light',
fontSize: 'medium',
currentBook: 1,
currentChapter: 1,
bookmarks: [],
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
setFontSize: (fontSize) => set({ fontSize }),
setCurrentBook: (currentBook) => set({ currentBook }),
setCurrentChapter: (currentChapter) => set({ currentChapter }),
addBookmark: (bookmark) => set((state) => ({
bookmarks: [...state.bookmarks, bookmark]
})),
removeBookmark: (bookmarkId) => set((state) => ({
bookmarks: state.bookmarks.filter(b => b.id !== bookmarkId)
})),
}),
{
name: 'bible-chat-storage',
}
)
)

90
lib/theme.ts Normal file
View File

@@ -0,0 +1,90 @@
'use client'
import { createTheme } from '@mui/material/styles'
export const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#2C5F6B', // Deep teal for spiritual feel
light: '#5A8A96',
dark: '#1A3B42',
contrastText: '#ffffff',
},
secondary: {
main: '#8B7355', // Warm brown for earth tones
light: '#B09A7A',
dark: '#5D4D37',
contrastText: '#ffffff',
},
background: {
default: '#FAFAFA',
paper: '#FFFFFF',
},
text: {
primary: '#1A1A1A',
secondary: '#4A4A4A',
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
h1: {
fontSize: '2.5rem',
fontWeight: 600,
lineHeight: 1.2,
},
h2: {
fontSize: '2rem',
fontWeight: 600,
lineHeight: 1.3,
},
h3: {
fontSize: '1.5rem',
fontWeight: 500,
lineHeight: 1.4,
},
h4: {
fontSize: '1.25rem',
fontWeight: 500,
lineHeight: 1.4,
},
body1: {
fontSize: '1rem',
lineHeight: 1.6,
},
body2: {
fontSize: '0.875rem',
lineHeight: 1.5,
},
},
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: 8,
padding: '8px 16px',
},
},
},
MuiCard: {
styleOverrides: {
root: {
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.1)',
borderRadius: 12,
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
boxShadow: '0px 1px 4px rgba(0, 0, 0, 0.1)',
},
},
},
},
})
export default theme

125
lib/validation/index.ts Normal file
View File

@@ -0,0 +1,125 @@
import { z } from 'zod'
// User validation schemas
export const userRegistrationSchema = z.object({
email: z.string()
.email('Email invalid')
.min(3, 'Email-ul trebuie să aibă cel puțin 3 caractere')
.max(254, 'Email-ul trebuie să aibă maximum 254 caractere'),
password: z.string()
.min(8, 'Parola trebuie să aibă cel puțin 8 caractere')
.max(128, 'Parola trebuie să aibă maximum 128 caractere')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Parola trebuie să conțină cel puțin o literă mică, o literă mare și o cifră'),
name: z.string()
.min(2, 'Numele trebuie să aibă cel puțin 2 caractere')
.max(100, 'Numele trebuie să aibă maximum 100 caractere')
.optional()
})
export const userLoginSchema = z.object({
email: z.string().email('Email invalid'),
password: z.string().min(1, 'Parola este obligatorie')
})
// Chat validation schemas
export const chatMessageSchema = z.object({
messages: z.array(z.object({
role: z.enum(['user', 'assistant'], { required_error: 'Rolul este obligatoriu' }),
content: z.string()
.min(1, 'Conținutul mesajului este obligatoriu')
.max(2000, 'Mesajul trebuie să aibă maximum 2000 caractere')
})).min(1, 'Cel puțin un mesaj este obligatoriu'),
verseContext: z.string()
.max(1000, 'Contextul versetului trebuie să aibă maximum 1000 caractere')
.optional()
})
// Prayer validation schemas
export const prayerRequestSchema = z.object({
content: z.string()
.min(10, 'Cererea de rugăciune trebuie să aibă cel puțin 10 caractere')
.max(1000, 'Cererea de rugăciune trebuie să aibă maximum 1000 caractere')
.trim(),
isAnonymous: z.boolean().default(true)
})
// Bookmark validation schemas
export const bookmarkSchema = z.object({
verseId: z.string()
.uuid('ID-ul versetului trebuie să fie valid')
.min(1, 'ID-ul versetului este obligatoriu'),
note: z.string()
.max(500, 'Nota trebuie să aibă maximum 500 caractere')
.optional(),
color: z.string()
.regex(/^#[0-9A-F]{6}$/i, 'Culoarea trebuie să fie un cod hex valid')
.default('#FFD700')
})
// Note validation schemas
export const noteSchema = z.object({
verseId: z.string()
.uuid('ID-ul versetului trebuie să fie valid')
.min(1, 'ID-ul versetului este obligatoriu'),
content: z.string()
.min(1, 'Conținutul notei este obligatoriu')
.max(2000, 'Nota trebuie să aibă maximum 2000 caractere')
.trim()
})
// Search validation schemas
export const searchSchema = z.object({
q: z.string()
.min(1, 'Termenul de căutare este obligatoriu')
.max(200, 'Termenul de căutare trebuie să aibă maximum 200 caractere')
.trim(),
limit: z.coerce.number()
.min(1, 'Limita trebuie să fie cel puțin 1')
.max(50, 'Limita trebuie să fie maximum 50')
.default(10)
})
// Bible navigation validation schemas
export const chapterSchema = z.object({
book: z.coerce.number()
.min(1, 'ID-ul cărții trebuie să fie cel puțin 1')
.max(66, 'ID-ul cărții trebuie să fie maximum 66'),
chapter: z.coerce.number()
.min(1, 'Numărul capitolului trebuie să fie cel puțin 1')
.max(150, 'Numărul capitolului trebuie să fie maximum 150')
})
// User preferences validation schemas
export const userPreferenceSchema = z.object({
key: z.string()
.min(1, 'Cheia preferinței este obligatorie')
.max(50, 'Cheia preferinței trebuie să aibă maximum 50 caractere'),
value: z.string()
.max(500, 'Valoarea preferinței trebuie să aibă maximum 500 caractere')
})
// Reading history validation schemas
export const readingHistorySchema = z.object({
bookId: z.coerce.number()
.min(1, 'ID-ul cărții trebuie să fie cel puțin 1')
.max(66, 'ID-ul cărții trebuie să fie maximum 66'),
chapterNum: z.coerce.number()
.min(1, 'Numărul capitolului trebuie să fie cel puțin 1')
.max(150, 'Numărul capitolului trebuie să fie maximum 150'),
verseNum: z.coerce.number()
.min(1, 'Numărul versetului trebuie să fie cel puțin 1')
.max(200, 'Numărul versetului trebuie să fie maximum 200')
.optional()
})
// Export types for TypeScript
export type UserRegistration = z.infer<typeof userRegistrationSchema>
export type UserLogin = z.infer<typeof userLoginSchema>
export type ChatMessage = z.infer<typeof chatMessageSchema>
export type PrayerRequest = z.infer<typeof prayerRequestSchema>
export type BookmarkData = z.infer<typeof bookmarkSchema>
export type NoteData = z.infer<typeof noteSchema>
export type SearchParams = z.infer<typeof searchSchema>
export type ChapterParams = z.infer<typeof chapterSchema>
export type UserPreference = z.infer<typeof userPreferenceSchema>
export type ReadingHistory = z.infer<typeof readingHistorySchema>

110
lib/websocket/server.ts Normal file
View File

@@ -0,0 +1,110 @@
import { Server } from 'socket.io'
import { createServer } from 'http'
import { parse } from 'url'
import next from 'next'
import { prisma } from '@/lib/db'
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
let io: Server
export function initializeWebSocket(server: any) {
io = new Server(server, {
cors: {
origin: process.env.NEXTAUTH_URL || 'http://localhost:3000',
methods: ['GET', 'POST']
}
})
io.on('connection', (socket) => {
console.log('Client connected:', socket.id)
// Join prayer room
socket.on('join-prayer-room', () => {
socket.join('prayers')
console.log(`Socket ${socket.id} joined prayer room`)
})
// Handle new prayer
socket.on('new-prayer', async (data) => {
console.log('New prayer received:', data)
// Broadcast to all in prayer room
io.to('prayers').emit('prayer-added', data)
})
// Handle prayer count update
socket.on('pray-for', async (requestId) => {
try {
// Get client IP (simplified for development)
const clientIP = socket.handshake.address || 'unknown'
// Check if already prayed
const existingPrayer = await prisma.prayer.findUnique({
where: {
requestId_ipAddress: {
requestId,
ipAddress: clientIP
}
}
})
if (!existingPrayer) {
// Add new prayer
await prisma.prayer.create({
data: {
requestId,
ipAddress: clientIP
}
})
// Update prayer count
const updatedRequest = await prisma.prayerRequest.update({
where: { id: requestId },
data: {
prayerCount: {
increment: 1
}
}
})
// Broadcast updated count
io.to('prayers').emit('prayer-count-updated', {
requestId,
count: updatedRequest.prayerCount
})
}
} catch (error) {
console.error('Error updating prayer count:', error)
}
})
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id)
})
})
return io
}
export function getSocketIO() {
return io
}
// Start server if running this file directly
if (require.main === module) {
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url!, true)
handle(req, res, parsedUrl)
})
initializeWebSocket(server)
const port = process.env.WEBSOCKET_PORT || 3015
server.listen(port, () => {
console.log(`WebSocket server running on port ${port}`)
})
})
}