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

+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>
);
}