Add Docker support and containerization

- Add Dockerfile for Next.js application
- Configure standalone output for Docker
- Add .dockerignore file
- Add docker-compose.yml for easy deployment
- Update next.config.ts for Docker compatibility
This commit is contained in:
nmemmert committed 2025-10-22 12:42:41 -04:00
1 parent 91d417159d
commit fba1097380
20 files changed
+6085 -253

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
import { NextRequest, NextResponse } from 'next/server';
import { doc, getDoc } from 'firebase/firestore';
import { db } from '@/lib/firebase';
const nodemailer = require('nodemailer');
function generatePrayerEmailHTML(message: string, subject: string) {
// Parse the message to extract prayer details
const lines = message.split('\n');
let currentPrayer = null;
let prayers = [];
let summarySection = false;
for (const line of lines) {
if (line.includes('New prayer added') || line.includes('New praise added')) {
currentPrayer = { type: '', date: '', text: '', journal: '', prayFor: '' };
} else if (line.startsWith('Date: ')) {
if (currentPrayer) currentPrayer.date = line.replace('Date: ', '');
} else if (line.startsWith('Praying for: ')) {
if (currentPrayer) currentPrayer.prayFor = line.replace('Praying for: ', '');
} else if (line.startsWith('Journal: ')) {
if (currentPrayer) currentPrayer.journal = line.replace('Journal: ', '');
} else if (line.includes('--- Active Prayers Summary ---')) {
summarySection = true;
prayers.push({ type: 'summary-header', text: 'Active Prayers Summary' });
} else if (summarySection && line.includes(' - ')) {
// Parse summary lines like "2025-01-15 - PRAYER: prayer text"
const match = line.match(/(\d{4}-\d{2}-\d{2}) - (PRAYER|PRAISE): (.+)/);
if (match) {
prayers.push({
type: 'summary-item',
date: match[1],
prayerType: match[2].toLowerCase(),
text: match[3]
});
}
} else if (currentPrayer && !currentPrayer.text && line.trim()) {
currentPrayer.text = line;
prayers.push(currentPrayer);
currentPrayer = null;
}
}
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
background-color: #f8f9fa;
padding: 20px;
}
.container {
background-color: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.header .subtitle {
margin: 8px 0 0 0;
opacity: 0.9;
font-size: 16px;
}
.content {
padding: 30px 20px;
}
.prayer-card {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 20px;
margin: 20px 0;
border-radius: 8px;
}
.prayer-type {
font-weight: 600;
color: #667eea;
text-transform: uppercase;
font-size: 14px;
letter-spacing: 0.5px;
}
.prayer-date {
color: #666;
font-size: 14px;
margin-bottom: 10px;
}
.prayer-text {
font-size: 16px;
line-height: 1.6;
margin: 10px 0;
}
.prayer-journal {
background: #fff;
border-left: 3px solid #764ba2;
padding: 15px;
margin: 15px 0;
font-style: italic;
color: #555;
}
.pray-for {
background: #e8f4fd;
padding: 10px 15px;
border-radius: 6px;
margin: 10px 0;
font-weight: 500;
color: #2c5282;
}
.summary-section {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
padding: 20px;
margin: 20px 0;
border-radius: 8px;
}
.summary-section h3 {
margin: 0 0 15px 0;
font-size: 18px;
}
.summary-item {
background: rgba(255, 255, 255, 0.1);
padding: 15px;
margin: 10px 0;
border-radius: 6px;
}
.summary-item .date {
color: #ffed4e;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.summary-item .prayer-type {
color: #ffed4e;
font-size: 12px;
margin-left: 10px;
}
.footer {
background: #f8f9fa;
padding: 20px;
text-align: center;
color: #666;
font-size: 14px;
}
.verse {
background: #667eea;
color: white;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
text-align: center;
}
.verse blockquote {
font-style: italic;
font-size: 16px;
margin: 0 0 10px 0;
}
.verse cite {
font-size: 14px;
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🙏 Prayer App</h1>
<div class="subtitle">${subject}</div>
</div>
<div class="content">
${prayers.map(prayer => {
if (prayer.type === 'summary-header') {
return `
<div class="summary-section">
<h3>📚 ${prayer.text}</h3>
<p>Here are all your active prayers and praises:</p>
</div>
`;
} else if (prayer.type === 'summary-item') {
return `
<div class="summary-item">
<div class="date">${prayer.date ? new Date(prayer.date).toLocaleDateString() : 'Unknown Date'}</div>
<div class="prayer-type">${prayer.prayerType}</div>
<div class="prayer-text">${prayer.text}</div>
</div>
`;
} else {
return `
<div class="prayer-card">
<div class="prayer-type">${prayer.type}</div>
<div class="prayer-date">${prayer.date ? new Date(prayer.date).toLocaleDateString() : 'Unknown Date'}</div>
<div class="prayer-text">${prayer.text}</div>
${prayer.prayFor ? `<div class="pray-for">🙋 Praying for: ${prayer.prayFor}</div>` : ''}
${prayer.journal && prayer.journal !== 'None' ? `<div class="prayer-journal">📝 Journal: ${prayer.journal}</div>` : ''}
</div>
`;
}
}).join('')}
<div class="verse">
<blockquote>"Pray without ceasing"</blockquote>
<cite>— 1 Thessalonians 5:17</cite>
</div>
</div>
<div class="footer">
<p>Sent with ❤️ from your Prayer App</p>
<p>This email helps you stay connected to your spiritual journey.</p>
</div>
</div>
</body>
</html>`;
return html;
}
export async function POST(request: NextRequest) {
try {
const { email, subject, message } = await request.json();
// Get SMTP config from Firestore
const configDoc = await getDoc(doc(db, 'config', 'smtp'));
if (!configDoc.exists()) {
return NextResponse.json({ error: 'SMTP configuration not found' }, { status: 500 });
}
const smtpConfig = configDoc.data();
// Create transporter with dynamic config
const transporter = nodemailer.createTransport({
host: smtpConfig.host,
port: smtpConfig.port,
secure: smtpConfig.secure,
auth: {
user: smtpConfig.user,
pass: smtpConfig.pass,
},
});
// Send email
await transporter.sendMail({
from: smtpConfig.from,
to: email,
subject,
text: message,
html: generatePrayerEmailHTML(message, subject),
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Email send error:', error);
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 });
}
}
+8 -4
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { AuthProvider } from "@/context/AuthContext";
import "./globals.css";
const geistSans = Geist({
@@ -13,8 +14,9 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Prayer App",
description: "A personal prayer and praise journaling app",
manifest: "/manifest.json",
};
export default function RootLayout({
@@ -23,11 +25,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className="dark">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
+588 -56
View File
@@ -1,65 +1,597 @@
import Image from "next/image";
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import Login from '@/components/Login';
import Admin from '@/components/Admin';
interface Prayer {
id: string;
date: string;
type: 'prayer' | 'praise';
text: string;
journal: string;
email: string;
prayFor: string;
archived?: boolean;
reminderFrequency?: 'daily' | 'weekly' | 'monthly' | 'never';
includeActiveSummary?: boolean;
}
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
const [prayers, setPrayers] = useState<Prayer[]>([]);
const [form, setForm] = useState({
date: new Date().toISOString().split('T')[0],
type: 'prayer' as 'prayer' | 'praise',
text: '',
journal: '',
email: '',
prayFor: '',
reminderFrequency: 'never' as 'daily' | 'weekly' | 'monthly' | 'never',
includeActiveSummary: false,
});
const [showAdmin, setShowAdmin] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [activeTab, setActiveTab] = useState<'add' | 'active' | 'archived' | 'search'>('add');
const itemsPerPage = 10;
const { user, loading, logout, isAdmin } = useAuth();
const [showLoadingScreen, setShowLoadingScreen] = useState(true);
// Ensure loading screen shows for at least 5 seconds
useEffect(() => {
const timer = setTimeout(() => {
setShowLoadingScreen(false);
}, 5000); // 5 seconds minimum
return () => clearTimeout(timer);
}, []);
useEffect(() => {
const stored = localStorage.getItem('prayers');
if (stored) {
setPrayers(JSON.parse(stored));
}
}, []);
useEffect(() => {
localStorage.setItem('prayers', JSON.stringify(prayers));
}, [prayers]);
// Auto-populate email field with user's email
useEffect(() => {
if (user?.email) {
setForm(prev => ({ ...prev, email: user.email! }));
}
}, [user]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newPrayer: Prayer = {
id: Date.now().toString(),
...form,
};
setPrayers(prev => [...prev, newPrayer]);
if (form.email && form.reminderFrequency !== 'never') {
try {
let message = `Date: ${form.date}\n\n${form.text}\n\nJournal: ${form.journal || 'None'}`;
if (form.prayFor) {
message += `\n\nPraying for: ${form.prayFor}`;
}
if (form.includeActiveSummary) {
const activePrayers = prayers.filter(prayer => !prayer.archived);
if (activePrayers.length > 0) {
message += `\n\n--- Active Prayers Summary ---\n`;
activePrayers.forEach(prayer => {
message += `\n${prayer.date} - ${prayer.type.toUpperCase()}: ${prayer.text}`;
if (prayer.prayFor) {
message += ` (Praying for: ${prayer.prayFor})`;
}
if (prayer.journal) {
message += `\n Journal: ${prayer.journal}`;
}
message += `\n`;
});
}
}
await fetch('/api/send-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: form.email,
subject: `New ${form.type} added - ${form.reminderFrequency} reminders enabled`,
message: message,
}),
});
} catch (error) {
console.error('Failed to send email:', error);
}
}
setForm({
date: new Date().toISOString().split('T')[0],
type: 'prayer',
text: '',
journal: '',
email: user?.email || '',
prayFor: '',
reminderFrequency: 'never',
includeActiveSummary: false,
});
};
const activePrayers = prayers.filter(prayer => !prayer.archived);
const archivedPrayers = prayers.filter(prayer => prayer.archived);
// Filter prayers based on search term
const filteredActivePrayers = activePrayers.filter(prayer =>
prayer.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.journal.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.type.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredArchivedPrayers = archivedPrayers.filter(prayer =>
prayer.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.journal.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.type.toLowerCase().includes(searchTerm.toLowerCase())
);
// For search tab, combine all filtered prayers
const allPrayers = [...activePrayers, ...archivedPrayers];
const filteredSearchPrayers = allPrayers.filter(prayer =>
prayer.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.journal.toLowerCase().includes(searchTerm.toLowerCase()) ||
prayer.type.toLowerCase().includes(searchTerm.toLowerCase())
);
// Pagination logic
const totalActivePages = Math.ceil(filteredActivePrayers.length / itemsPerPage);
const totalArchivedPages = Math.ceil(filteredArchivedPrayers.length / itemsPerPage);
const totalSearchPages = Math.ceil(filteredSearchPrayers.length / itemsPerPage);
const paginatedActivePrayers = filteredActivePrayers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const paginatedArchivedPrayers = filteredArchivedPrayers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const paginatedSearchPrayers = filteredSearchPrayers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const currentPrayers = activeTab === 'active' ? paginatedActivePrayers :
activeTab === 'archived' ? paginatedArchivedPrayers :
paginatedSearchPrayers;
const currentTotalPages = activeTab === 'active' ? totalActivePages :
activeTab === 'archived' ? totalArchivedPages :
totalSearchPages;
const groupedPrayers = currentPrayers.reduce((acc, prayer) => {
if (!acc[prayer.date]) acc[prayer.date] = [];
acc[prayer.date].push(prayer);
return acc;
}, {} as Record<string, Prayer[]>);
const sharePrayer = async (prayer: Prayer) => {
const shareText = `${prayer.type.toUpperCase()}: ${prayer.text}${prayer.journal ? `\n\nJournal: ${prayer.journal}` : ''}`;
if (navigator.share) {
try {
await navigator.share({
title: `Shared ${prayer.type}`,
text: shareText,
});
// Show success feedback
const button = document.activeElement as HTMLElement;
if (button) {
const originalText = button.textContent;
button.textContent = '✅ Shared!';
button.classList.add('bg-green-600', 'hover:bg-green-700');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('bg-green-600', 'hover:bg-green-700');
}, 2000);
}
} catch (err) {
console.log('Share cancelled');
}
} else {
// Fallback: copy to clipboard
try {
await navigator.clipboard.writeText(shareText);
// Show success feedback
const button = document.activeElement as HTMLElement;
if (button) {
const originalText = button.textContent;
button.textContent = '📋 Copied!';
button.classList.add('bg-green-600', 'hover:bg-green-700');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('bg-green-600', 'hover:bg-green-700');
}, 2000);
}
} catch (clipboardErr) {
alert('Prayer copied to clipboard!');
}
}
};
if (loading || showLoadingScreen) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-black to-gray-800 flex items-center justify-center p-4">
<div className="text-center">
{/* Animated prayer icon */}
<div className="mb-8 relative">
<div className="w-24 h-24 mx-auto relative">
{/* Outer ring */}
<div className="absolute inset-0 border-4 border-blue-500/30 rounded-full animate-spin"></div>
{/* Inner ring */}
<div className="absolute inset-2 border-4 border-transparent border-t-blue-400 rounded-full animate-spin" style={{animationDirection: 'reverse', animationDuration: '1.5s'}}></div>
{/* Center icon */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-3xl animate-pulse">🙏</div>
</div>
</div>
{/* Floating particles */}
<div className="absolute -top-2 -right-2 w-3 h-3 bg-blue-400 rounded-full animate-bounce" style={{animationDelay: '0s'}}></div>
<div className="absolute -bottom-1 -left-3 w-2 h-2 bg-purple-400 rounded-full animate-bounce" style={{animationDelay: '0.5s'}}></div>
<div className="absolute top-1/2 -right-4 w-2 h-2 bg-green-400 rounded-full animate-bounce" style={{animationDelay: '1s'}}></div>
</div>
{/* Loading text */}
<h1 className="text-2xl font-bold text-white mb-2 animate-pulse">
Prayer App
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<p className="text-gray-400 text-lg mb-4">
Preparing your spiritual journey...
</p>
{/* Inspirational quote */}
<div className="max-w-xs mx-auto">
<blockquote className="text-gray-300 italic text-sm leading-relaxed">
"Be anxious for nothing, but in everything by prayer and supplication, with thanksgiving, let your requests be made known to God."
</blockquote>
<cite className="text-gray-500 text-xs mt-2 block">
— Philippians 4:6
</cite>
</div>
{/* Progress dots */}
<div className="flex justify-center space-x-2 mt-8">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{animationDelay: '0s'}}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{animationDelay: '0.2s'}}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{animationDelay: '0.4s'}}></div>
</div>
</div>
</div>
);
}
if (!user) {
return <Login />;
}
return (
<div className="min-h-screen bg-black text-white p-4">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold">Prayer App</h1>
<p className="text-gray-400 text-sm mt-1">
{activeTab === 'add' ? 'Add New Prayer or Praise' :
activeTab === 'search' ? 'Search Results' :
`${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} Prayers`}
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<div className="flex gap-4">
{isAdmin && (
<button
onClick={() => setShowAdmin(!showAdmin)}
className="px-4 py-3 bg-purple-600 hover:bg-purple-700 rounded text-base font-medium"
>
{showAdmin ? 'Hide Admin' : 'Admin'}
</button>
)}
<button onClick={() => logout()} className="px-4 py-3 bg-red-600 hover:bg-red-700 rounded text-base font-medium">
Logout
</button>
</div>
</main>
</div>
{showAdmin && <Admin />}
{activeTab === 'add' && (
<form onSubmit={handleSubmit} className="max-w-md mx-auto mb-8 space-y-6">
<div>
<label className="block mb-2 text-lg">Date</label>
<input
type="date"
value={form.date}
onChange={e => setForm(prev => ({ ...prev, date: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
required
/>
</div>
<div>
<label className="block mb-2 text-lg">Type</label>
<select
value={form.type}
onChange={e => setForm(prev => ({ ...prev, type: e.target.value as 'prayer' | 'praise' }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
>
<option value="prayer">Prayer</option>
<option value="praise">Praise</option>
</select>
</div>
<div>
<label className="block mb-2 text-lg">Pray For (Person)</label>
<input
type="text"
value={form.prayFor}
onChange={e => setForm(prev => ({ ...prev, prayFor: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
placeholder="Name of person to pray for..."
/>
</div>
<div>
<label className="block mb-2 text-lg">Text</label>
<textarea
value={form.text}
onChange={e => setForm(prev => ({ ...prev, text: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base min-h-[100px]"
placeholder="Enter your prayer or praise..."
required
/>
</div>
<div>
<label className="block mb-2 text-lg">Journal</label>
<textarea
value={form.journal}
onChange={e => setForm(prev => ({ ...prev, journal: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base min-h-[100px]"
placeholder="Optional journal entry..."
/>
</div>
<div>
<label className="block mb-2 text-lg">Email (for reminders)</label>
<input
type="email"
value={form.email}
onChange={e => setForm(prev => ({ ...prev, email: e.target.value }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
placeholder="your@email.com"
/>
</div>
<div>
<label className="block mb-2 text-lg">Reminder Frequency</label>
<select
value={form.reminderFrequency}
onChange={e => setForm(prev => ({ ...prev, reminderFrequency: e.target.value as 'daily' | 'weekly' | 'monthly' | 'never' }))}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded text-base"
>
<option value="never">Never</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div className="flex items-center mb-4">
<input
type="checkbox"
checked={form.includeActiveSummary}
onChange={e => setForm(prev => ({ ...prev, includeActiveSummary: e.target.checked }))}
className="w-6 h-6 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label className="ml-3 text-base font-medium" onClick={() => setForm(prev => ({ ...prev, includeActiveSummary: !prev.includeActiveSummary }))}>
Include active prayers summary in reminders
</label>
</div>
<button type="submit" className="w-full p-4 bg-blue-600 hover:bg-blue-700 rounded text-lg font-semibold mt-6">
Add Entry
</button>
</form>
)}
{activeTab !== 'add' && (
<div className="max-w-4xl mx-auto">
{activeTab === 'search' && (
<div className="mb-6">
<input
type="text"
placeholder="Search all prayers..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1); // Reset to first page when searching
}}
className="w-full p-3 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
{searchTerm && (
<p className="text-gray-400 text-sm mt-2">
Found {filteredSearchPrayers.length} prayer{filteredSearchPrayers.length !== 1 ? 's' : ''}
</p>
)}
</div>
)}
{currentPrayers.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-500">
{activeTab === 'search' && searchTerm ? 'No prayers found matching your search.' : 'No prayers or praises found.'}
</p>
</div>
)}
{Object.keys(groupedPrayers).sort().reverse().map(date => (
<div key={date} className="mb-8">
<h2 className="text-xl font-semibold mb-4">{new Date(date).toLocaleDateString()}</h2>
{groupedPrayers[date].map(prayer => (
<div key={prayer.id} className={`bg-gray-800 p-4 rounded mb-4 ${prayer.archived || activeTab === 'search' ? 'opacity-75 border border-gray-600' : ''}`}>
<div className="flex justify-between items-start mb-3">
<span className="font-medium capitalize text-lg">{prayer.type}</span>
<button
onClick={() => sharePrayer(prayer)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg flex items-center gap-2 transition-all duration-200 shadow-md hover:shadow-lg"
title="Share this prayer"
>
📤 Share
</button>
</div>
<p className="mb-2">{prayer.text}</p>
{prayer.journal && (
<div className="border-t border-gray-600 pt-2">
<p className="text-sm text-gray-400">Journal: {prayer.journal}</p>
</div>
)}
{prayer.prayFor && (
<div className="border-t border-gray-600 pt-2">
<p className="text-sm text-gray-400">Pray For: {prayer.prayFor}</p>
</div>
)}
{activeTab !== 'search' && (
<div className="flex justify-end mt-3 pt-2 border-t border-gray-600">
{prayer.archived ? (
<button
onClick={() => {
setPrayers(prev => prev.map(p =>
p.id === prayer.id ? { ...p, archived: false } : p
));
}}
className="px-3 py-1 text-green-500 hover:text-green-400 text-sm font-medium hover:bg-gray-700 rounded transition-colors"
title="Restore prayer"
>
↩️ Restore
</button>
) : (
<button
onClick={() => {
setPrayers(prev => prev.map(p =>
p.id === prayer.id ? { ...p, archived: true } : p
));
}}
className="px-3 py-1 text-red-500 hover:text-red-400 text-sm font-medium hover:bg-gray-700 rounded transition-colors"
title="Archive prayer"
>
📂 Archive
</button>
)}
</div>
)}
</div>
))}
</div>
))}
{/* Pagination */}
{currentTotalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8 mb-6">
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed rounded text-white"
>
Previous
</button>
<span className="text-gray-300">
Page {currentPage} of {currentTotalPages}
</span>
<button
onClick={() => setCurrentPage(prev => Math.min(currentTotalPages, prev + 1))}
disabled={currentPage === currentTotalPages}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed rounded text-white"
>
Next
</button>
</div>
)}
</div>
)}
{/* Bottom Navigation Tabs */}
<div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-gray-900 via-gray-900 to-gray-800 border-t border-gray-700 shadow-2xl">
<div className="grid grid-cols-4 px-2 py-2">
<button
onClick={() => {
setActiveTab('add');
setCurrentPage(1);
}}
className={`mx-1 py-3 px-2 rounded-xl text-center transition-all duration-200 text-sm font-semibold flex flex-col items-center justify-center min-h-[60px] ${
activeTab === 'add'
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white shadow-lg transform scale-105'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50 active:bg-gray-700/50'
}`}
>
<span className="text-lg mb-1">➕</span>
<span className="text-xs leading-tight">Add</span>
</button>
<button
onClick={() => {
setActiveTab('active');
setCurrentPage(1);
setSearchTerm(''); // Clear search when switching to active
}}
className={`mx-1 py-3 px-2 rounded-xl text-center transition-all duration-200 text-sm font-semibold flex flex-col items-center justify-center min-h-[60px] ${
activeTab === 'active'
? 'bg-gradient-to-br from-green-600 to-green-700 text-white shadow-lg transform scale-105'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50 active:bg-gray-700/50'
}`}
>
<span className="text-lg mb-1">📖</span>
<span className="text-xs leading-tight">Active</span>
</button>
<button
onClick={() => {
setActiveTab('archived');
setCurrentPage(1);
setSearchTerm(''); // Clear search when switching to archived
}}
className={`mx-1 py-3 px-2 rounded-xl text-center transition-all duration-200 text-sm font-semibold flex flex-col items-center justify-center min-h-[60px] ${
activeTab === 'archived'
? 'bg-gradient-to-br from-purple-600 to-purple-700 text-white shadow-lg transform scale-105'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50 active:bg-gray-700/50'
}`}
>
<span className="text-lg mb-1">📂</span>
<span className="text-xs leading-tight">Archived</span>
</button>
<button
onClick={() => {
setActiveTab('search');
setCurrentPage(1);
}}
className={`mx-1 py-3 px-2 rounded-xl text-center transition-all duration-200 text-sm font-semibold flex flex-col items-center justify-center min-h-[60px] ${
activeTab === 'search'
? 'bg-gradient-to-br from-orange-600 to-orange-700 text-white shadow-lg transform scale-105'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50 active:bg-gray-700/50'
}`}
>
<span className="text-lg mb-1">🔍</span>
<span className="text-xs leading-tight">Search</span>
</button>
</div>
</div>
{/* Add padding to account for fixed bottom nav */}
<div className="pb-20"></div>
</div>
);
}
+309
View File
@@ -0,0 +1,309 @@
'use client';
import { useState, useEffect } from 'react';
import { doc, getDoc, setDoc } from 'firebase/firestore';
import { db } from '@/lib/firebase';
import { useAuth } from '@/context/AuthContext';
interface SMTPConfig {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
from: string;
}
interface AdminConfig {
adminEmails: string[];
}
export default function Admin() {
const { user, isAdmin } = useAuth();
const [config, setConfig] = useState<SMTPConfig>({
host: '',
port: 587,
secure: false,
user: '',
pass: '',
from: '',
});
const [adminConfig, setAdminConfig] = useState<AdminConfig>({
adminEmails: [],
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [newAdminEmail, setNewAdminEmail] = useState('');
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
// Load SMTP config
const smtpDoc = await getDoc(doc(db, 'config', 'smtp'));
if (smtpDoc.exists()) {
setConfig(smtpDoc.data() as SMTPConfig);
}
// Load admin config
const adminDoc = await getDoc(doc(db, 'config', 'admin'));
if (adminDoc.exists()) {
setAdminConfig(adminDoc.data() as AdminConfig);
} else {
// Default admin config
setAdminConfig({ adminEmails: ['your-admin-email@example.com'] });
}
} catch (error) {
console.error('Error loading config:', error);
} finally {
setLoading(false);
}
};
const saveConfig = async () => {
setSaving(true);
setMessage('');
try {
await setDoc(doc(db, 'config', 'smtp'), config);
setMessage('SMTP configuration saved successfully!');
} catch (error) {
console.error('Error saving config:', error);
setMessage('Error saving configuration.');
} finally {
setSaving(false);
}
};
const saveAdminConfig = async () => {
setSaving(true);
setMessage('');
try {
await setDoc(doc(db, 'config', 'admin'), adminConfig);
setMessage('Admin configuration saved successfully!');
} catch (error) {
console.error('Error saving admin config:', error);
setMessage('Error saving admin configuration.');
} finally {
setSaving(false);
}
};
const addAdminEmail = async () => {
if (!newAdminEmail.trim()) return;
const updatedEmails = [...adminConfig.adminEmails, newAdminEmail.trim()];
const updatedConfig = { ...adminConfig, adminEmails: updatedEmails };
try {
await setDoc(doc(db, 'config', 'admin'), updatedConfig);
setAdminConfig(updatedConfig);
setNewAdminEmail('');
setMessage('Admin email added successfully!');
} catch (error) {
console.error('Error adding admin email:', error);
setMessage('Error adding admin email.');
}
};
const removeAdminEmail = async (email: string) => {
const updatedEmails = adminConfig.adminEmails.filter(e => e !== email);
const updatedConfig = { ...adminConfig, adminEmails: updatedEmails };
try {
await setDoc(doc(db, 'config', 'admin'), updatedConfig);
setAdminConfig(updatedConfig);
setMessage('Admin email removed successfully!');
} catch (error) {
console.error('Error removing admin email:', error);
setMessage('Error removing admin email.');
}
};
if (!isAdmin) {
return (
<div className="max-w-2xl mx-auto p-6 bg-red-900 border border-red-700 rounded-lg">
<h2 className="text-xl font-bold text-red-100 mb-2">Access Denied</h2>
<p className="text-red-200">You don't have permission to access the admin panel.</p>
</div>
);
}
if (loading) {
return <div className="p-4">Loading admin settings...</div>;
}
return (
<div className="max-w-2xl mx-auto p-6 bg-gray-800 rounded-lg">
<h2 className="text-2xl font-bold mb-6 text-white">Admin Settings</h2>
<div className="space-y-4">
<h3 className="text-xl font-semibold text-white mb-4">SMTP Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">SMTP Host</label>
<input
type="text"
value={config.host}
onChange={(e) => setConfig(prev => ({ ...prev, host: e.target.value }))}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded text-white"
placeholder="smtp.gmail.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Port</label>
<input
type="number"
value={config.port}
onChange={(e) => setConfig(prev => ({ ...prev, port: parseInt(e.target.value) }))}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Username</label>
<input
type="text"
value={config.user}
onChange={(e) => setConfig(prev => ({ ...prev, user: e.target.value }))}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded text-white"
placeholder="your-email@gmail.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Password</label>
<input
type="password"
value={config.pass}
onChange={(e) => setConfig(prev => ({ ...prev, pass: e.target.value }))}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded text-white"
placeholder="your-app-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">From Email</label>
<input
type="email"
value={config.from}
onChange={(e) => setConfig(prev => ({ ...prev, from: e.target.value }))}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded text-white"
placeholder="noreply@yourapp.com"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="secure"
checked={config.secure}
onChange={(e) => setConfig(prev => ({ ...prev, secure: e.target.checked }))}
className="mr-2"
/>
<label htmlFor="secure" className="text-sm font-medium text-gray-300">Use SSL/TLS</label>
</div>
</div>
<div className="pt-4">
<button
onClick={saveConfig}
disabled={saving}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 rounded text-white font-medium"
>
{saving ? 'Saving...' : 'Save SMTP Settings'}
</button>
{message && (
<p className={`mt-2 text-sm ${message.includes('Error') ? 'text-red-400' : 'text-green-400'}`}>
{message}
</p>
)}
</div>
<h3 className="text-xl font-semibold text-white mb-4">Admin Configuration</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Admin Emails</label>
<input
type="text"
value={adminConfig.adminEmails.join(', ')}
onChange={(e) => setAdminConfig(prev => ({ ...prev, adminEmails: e.target.value.split(',').map(email => email.trim()) }))}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded text-white"
placeholder="admin1@example.com, admin2@example.com"
/>
</div>
</div>
<div className="flex gap-2">
<input
type="text"
value={newAdminEmail}
onChange={(e) => setNewAdminEmail(e.target.value)}
className="flex-1 p-2 bg-gray-700 border border-gray-600 rounded text-white"
placeholder="new-admin@example.com"
/>
<button
onClick={addAdminEmail}
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-white font-medium"
>
Add Email
</button>
</div>
<div className="pt-4">
<button
onClick={saveAdminConfig}
disabled={saving}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 rounded text-white font-medium"
>
{saving ? 'Saving...' : 'Save Admin Settings'}
</button>
</div>
<div className="border-t border-gray-600 pt-6 mt-6">
<h3 className="text-xl font-semibold text-white mb-4">Admin Email Management</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">Current Admin Emails</label>
<div className="space-y-2">
{adminConfig.adminEmails.map((email, index) => (
<div key={index} className="flex items-center justify-between bg-gray-700 p-2 rounded">
<span className="text-gray-200">{email}</span>
<button
onClick={() => removeAdminEmail(email)}
className="text-red-400 hover:text-red-300"
>
Remove
</button>
</div>
))}
</div>
</div>
<div className="flex gap-2">
<input
type="email"
value={newAdminEmail}
onChange={(e) => setNewAdminEmail(e.target.value)}
placeholder="new-admin@example.com"
className="flex-1 p-2 bg-gray-700 border border-gray-600 rounded text-white"
/>
<button
onClick={addAdminEmail}
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-white"
>
Add Admin
</button>
</div>
</div>
</div>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/context/AuthContext';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSignup, setIsSignup] = useState(false);
const [error, setError] = useState('');
const { login, signup } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (isSignup) {
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
try {
await signup(email, password);
} catch (err: any) {
setError(err.message);
}
} else {
try {
await login(email, password);
} catch (err: any) {
setError(err.message);
}
}
};
return (
<div className="min-h-screen bg-black text-white flex items-center justify-center">
<form onSubmit={handleSubmit} className="bg-gray-800 p-8 rounded-lg w-full max-w-md">
<h1 className="text-2xl font-bold mb-6 text-center">
{isSignup ? 'Sign Up for Prayer App' : 'Login to Prayer App'}
</h1>
{error && <p className="text-red-500 mb-4">{error}</p>}
<div className="mb-4">
<label className="block mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded"
required
/>
</div>
<div className="mb-4">
<label className="block mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded"
required
/>
</div>
{isSignup && (
<div className="mb-6">
<label className="block mb-2">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded"
required
/>
</div>
)}
<button type="submit" className="w-full p-2 bg-blue-600 hover:bg-blue-700 rounded mb-4">
{isSignup ? 'Sign Up' : 'Login'}
</button>
<button
type="button"
onClick={() => {
setIsSignup(!isSignup);
setError('');
setConfirmPassword('');
}}
className="w-full p-2 bg-gray-600 hover:bg-gray-700 rounded"
>
{isSignup ? 'Already have an account? Login' : 'Need an account? Sign Up'}
</button>
</form>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { User, signInWithEmailAndPassword, signOut, onAuthStateChanged, createUserWithEmailAndPassword } from 'firebase/auth';
import { auth } from '@/lib/firebase';
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
signup: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
loading: boolean;
isAdmin: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user);
setLoading(false);
});
return unsubscribe;
}, []);
// Configure admin email addresses here - only these users will have admin access
const adminEmails = ['nate@necloud.us', 'nmemmert@outlook.com'];
const isAdmin = user ? adminEmails.includes(user.email || '') : false;
const login = async (email: string, password: string) => {
await signInWithEmailAndPassword(auth, email, password);
};
const signup = async (email: string, password: string) => {
await createUserWithEmailAndPassword(auth, email, password);
};
const logout = async () => {
await signOut(auth);
};
return (
<AuthContext.Provider value={{ user, login, signup, logout, loading, isAdmin }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+26
View File
@@ -0,0 +1,26 @@
// Import the functions you need from the SDKs you need
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
const firebaseConfig = {
apiKey: "AIzaSyAHtvhdtBxNwf0UE3Fp5P0v1vITZKhLMJw",
authDomain: "prayer-app-6701f.firebaseapp.com",
projectId: "prayer-app-6701f",
storageBucket: "prayer-app-6701f.firebasestorage.app",
messagingSenderId: "555011794707",
appId: "1:555011794707:web:a3d60bdee742578cca1ce6"
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
// Initialize Firebase Authentication and get a reference to the service
export const auth = getAuth(app);
// Initialize Cloud Firestore and get a reference to the service
export const db = getFirestore(app);