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
+6083 -251

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
Dockerfile
.dockerignore
.git
.gitignore
README.md
.env*.local
.next
.vercel
*.md
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Testing
coverage
# Next.js
.next/
out/
# Production
build
# Misc
.DS_Store
*.pem
# Debug
*.log
# Local env files
.env*.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
+23
View File
@@ -0,0 +1,23 @@
- [x] Verify that the copilot-instructions.md file in the .github directory is created.
- [x] Clarify Project Requirements
- [x] Scaffold the Project
Completed: Scaffolded Next.js project with TypeScript, Tailwind CSS, ESLint, and App Router.
- [x] Customize the Project
- [x] Install Required Extensions
- [x] Compile the Project
Completed: Built successfully with webpack, PWA configured.
- [x] Create and Run Task
- [x] Launch the Project
- [x] Ensure Documentation is Complete
- Work through each checklist item systematically.
- Keep communication concise and focused.
- Follow development best practices.
+62
View File
@@ -0,0 +1,62 @@
# Use the official Node.js 18 Alpine image as the base
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN \
if [ -f package-lock.json ]; then npm ci --only=production; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]
+76 -2
View File
@@ -1,8 +1,68 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Prayer App
A Progressive Web App (PWA) for journaling prayers and praises, organized by date, with journaling functionality and email reminders.
## Features
- User authentication with Firebase (login and signup)
- Add prayers and praises with date organization
- Journal entries for each prayer
- "Pray For" field to specify a person for each prayer
- Email field auto-populated with user's email for reminders
- Archive prayers instead of deleting them
- View archived prayers with dedicated tab
- Search prayers by content, type, or journal (dedicated Search tab)
- Pagination for large numbers of entries
- Share prayers on social media, email, and text
- Prominent share buttons with visual feedback
- Admin panel for SMTP configuration and user management
- Email notifications (requires configuration)
- Reminder frequency options (daily, weekly, monthly, or never)
- Option to include active prayers summary in reminder emails
- Beautiful HTML-formatted emails with prayer cards and visual styling
- PWA for offline access and installation
- Dark mode theme
- Bottom navigation tabs for easy switching (Add Entry, Active, Archived, Search)
- Engaging loading screen with animations and inspirational content (minimum 5-second display)
- Enhanced bottom navigation with gradients, shadows, and improved touch targets
## Setup
1. Create a Firebase project at https://console.firebase.google.com/
2. Enable Authentication with Email/Password provider
3. Enable Firestore Database
4. Get your Firebase config and replace the placeholder in `src/lib/firebase.ts`
Users can now register accounts directly in the app!
## Admin Setup
Admin access is restricted to specific email addresses. Admins can manage the admin email list directly in the app through the Admin panel.
**To add/remove admin emails:**
1. Log in as an existing admin
2. Click the "Admin" button
3. Scroll to "Admin Email Management" section
4. Add new admin emails or remove existing ones
5. Changes are saved automatically to Firestore
Admin users can access the admin panel by clicking the "Admin" button in the header. From there, they can configure:
- SMTP settings for email sending
- Admin email management
- Other server configurations
The admin panel saves settings to Firestore for persistent configuration.
## Getting Started
First, run the development server:
First, install dependencies:
```bash
npm install
```
Then, run the development server:
```bash
npm run dev
@@ -20,6 +80,20 @@ You can start editing the page by modifying `app/page.tsx`. The page auto-update
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Configuration
For email reminders, configure SMTP settings in `src/app/api/send-email/route.ts`.
## Build
```bash
npm run build
```
## Deploy
Deploy to Vercel or any platform supporting Next.js PWAs.
## Learn More
To learn more about Next.js, take a look at the following resources:
+18
View File
@@ -0,0 +1,18 @@
version: '3.8'
services:
prayer-app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
+7 -1
View File
@@ -1,7 +1,13 @@
import type { NextConfig } from "next";
const withPWA = require('next-pwa');
const nextConfig: NextConfig = {
output: 'standalone',
/* config options here */
};
export default nextConfig;
const config = process.env.NODE_ENV === 'production' ? withPWA({
dest: 'public',
})(nextConfig) : nextConfig;
export default config;
+4435 -178
View File
File diff suppressed because it is too large. Load diff
+12 -8
View File
@@ -3,24 +3,28 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"firebase": "^12.4.0",
"next": "16.0.0",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.9",
"react": "19.2.0",
"react-dom": "19.2.0",
"next": "16.0.0"
"react-dom": "19.2.0"
},
"devDependencies": {
"typescript": "^5",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "16.0.0"
"eslint-config-next": "16.0.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Prayer App",
"short_name": "Prayers",
"description": "A personal prayer and praise journaling app",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+1
View File
@@ -0,0 +1 @@
if(!self.define){let e,s={};const a=(a,n)=>(a=new URL(a+".js",n).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(n,t)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let c={};const r=e=>a(e,i),o={module:{uri:i},exports:c,require:r};s[i]=Promise.all(n.map(e=>o[e]||r(e))).then(e=>(t(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/139.678015000317a168.js",revision:"678015000317a168"},{url:"/_next/static/chunks/240-754372c10c8766c9.js",revision:"754372c10c8766c9"},{url:"/_next/static/chunks/4bd1b696-b7529f9b306ab05d.js",revision:"b7529f9b306ab05d"},{url:"/_next/static/chunks/512-8d2b9de6fda25175.js",revision:"8d2b9de6fda25175"},{url:"/_next/static/chunks/646.30443a55a43a3bfb.js",revision:"30443a55a43a3bfb"},{url:"/_next/static/chunks/7508b87c-1ab0c72b2a9e57da.js",revision:"1ab0c72b2a9e57da"},{url:"/_next/static/chunks/app/_global-error/page-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/app/_not-found/page-1123cfe416c748d0.js",revision:"1123cfe416c748d0"},{url:"/_next/static/chunks/app/api/send-email/route-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/app/layout-dc9d7c3da9cf59ef.js",revision:"dc9d7c3da9cf59ef"},{url:"/_next/static/chunks/app/page-d9fbb10ee3eb8a72.js",revision:"d9fbb10ee3eb8a72"},{url:"/_next/static/chunks/dd8162e8-b0853216adf21a86.js",revision:"b0853216adf21a86"},{url:"/_next/static/chunks/framework-292291387d6b2e39.js",revision:"292291387d6b2e39"},{url:"/_next/static/chunks/main-7ddb690de3906442.js",revision:"7ddb690de3906442"},{url:"/_next/static/chunks/main-app-4f19d69ed4295336.js",revision:"4f19d69ed4295336"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-bcba0732671665d2.js",revision:"bcba0732671665d2"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-167d4419e1b22999.js",revision:"167d4419e1b22999"},{url:"/_next/static/css/0e9b93944341831f.css",revision:"0e9b93944341831f"},{url:"/_next/static/hS4u_kF5nc-qn-M9O01uW/_buildManifest.js",revision:"009de90521464c9665092678c4f82fe9"},{url:"/_next/static/hS4u_kF5nc-qn-M9O01uW/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/media/4cf2300e9c8272f7-s.p.woff2",revision:"18bae71b1e1b2bb25321090a3b563103"},{url:"/_next/static/media/747892c23ea88013-s.woff2",revision:"a0761690ccf4441ace5cec893b82d4ab"},{url:"/_next/static/media/8d697b304b401681-s.woff2",revision:"cc728f6c0adb04da0dfcb0fc436a8ae5"},{url:"/_next/static/media/93f479601ee12b01-s.p.woff2",revision:"da83d5f06d825c5ae65b7cca706cb312"},{url:"/_next/static/media/9610d9e46709d722-s.woff2",revision:"7b7c0ef93df188a852344fc272fc096b"},{url:"/_next/static/media/ba015fad6dcf6784-s.woff2",revision:"8ea4f719af3312a055caf09f34c89a77"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/manifest.json",revision:"785fbc5941efd3caa27ade80c67bb5ef"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:a,state:n})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jLine truncated
+1
View File
@@ -0,0 +1 @@
define(["exports"],function(t){"use strict";try{self["workbox:core:6.5.4"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:6.5.4"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class i extends r{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=i&&i.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:r})}catch(t){c=Promise.reject(t)}const h=i&&i.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:r})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const r=this.t.get(s.method)||[];for(const i of r){let r;const a=i.match({url:t,sameOrigin:e,request:s,event:n});if(a)return r=a,(Array.isArray(r)&&0===r.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(r=void 0),{route:i,params:r}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new r(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new i(t,e,n);else if("function"==typeof t)a=new r(t,e,n);else{if(!(t instanceof r))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}try{self["workbox:strategies:6.5.4"]&&_()}catch(t){}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null},l={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},f=t=>[l.prefix,t,l.suffix].filter(t=>t&&t.length>0).join("-"),w=t=>t||f(l.precache),d=t=>t||f(l.runtime);function p(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class y{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const g=new Set;function m(t){return"string"==typeof t?new Request(t):t}class v{constructor(t,e){this.h={},Object.assign(this,e),this.event=e.event,this.u=t,this.l=new y,this.p=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.l.promise)}async fetch(t){const{event:e}=this;let n=m(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const r=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const i=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.u.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:i,response:t});return t}catch(t){throw r&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:r.clone(),request:i.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=m(t);let s;const{cacheName:n,matchOptions:r}=this.u,i=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(i,a);for(const t of this.iterateCLine truncated
+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 });
}
}
+7 -3
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`}
>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
+587 -55
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() {
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="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.
<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 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>
</main>
</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 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>
</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);
+19
View File
@@ -0,0 +1,19 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
darkMode: 'class',
} satisfies Config;
+14 -4
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,7 +23,9 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": [
@@ -28,7 +34,11 @@
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
"**/*.mts",
".next\\dev/types/**/*.ts",
".next\\dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}