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:
59
lib/ai/azure-openai.ts
Normal file
59
lib/ai/azure-openai.ts
Normal 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
44
lib/auth/index.ts
Normal 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
71
lib/cache/index.ts
vendored
Normal 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
11
lib/db.ts
Normal 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
46
lib/store/index.ts
Normal 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
90
lib/theme.ts
Normal 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
125
lib/validation/index.ts
Normal 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
110
lib/websocket/server.ts
Normal 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}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user