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:
1 parent
91d417159d
commit
fba1097380
20 files changed
+6085
-253
No files matched your search
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user