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,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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
|
## Getting Started
|
||||||
|
|
||||||
First, run the development server:
|
First, install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
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.
|
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
|
## Learn More
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,13 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
const withPWA = require('next-pwa');
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
/* config options here */
|
/* config options here */
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
const config = process.env.NODE_ENV === 'production' ? withPWA({
|
||||||
|
dest: 'public',
|
||||||
|
})(nextConfig) : nextConfig;
|
||||||
|
|
||||||
|
export default config;
|
||||||
Generated
+4435
-178
File diff suppressed because it is too large.
Load diff
+12
-8
@@ -3,24 +3,28 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0"
|
||||||
"next": "16.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.0"
|
"eslint-config-next": "16.0.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -13,8 +14,9 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Prayer App",
|
||||||
description: "Generated by create next app",
|
description: "A personal prayer and praise journaling app",
|
||||||
|
manifest: "/manifest.json",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +25,13 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="dark">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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() {
|
export default function Home() {
|
||||||
return (
|
const [prayers, setPrayers] = useState<Prayer[]>([]);
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
const [form, setForm] = useState({
|
||||||
<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">
|
date: new Date().toISOString().split('T')[0],
|
||||||
<Image
|
type: 'prayer' as 'prayer' | 'praise',
|
||||||
className="dark:invert"
|
text: '',
|
||||||
src="/next.svg"
|
journal: '',
|
||||||
alt="Next.js logo"
|
email: '',
|
||||||
width={100}
|
prayFor: '',
|
||||||
height={20}
|
reminderFrequency: 'never' as 'daily' | 'weekly' | 'monthly' | 'never',
|
||||||
priority
|
includeActiveSummary: false,
|
||||||
/>
|
});
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
const [showAdmin, setShowAdmin] = useState(false);
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
To get started, edit the page.tsx file.
|
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>
|
</h1>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<p className="text-gray-400 text-lg mb-4">
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
Preparing your spiritual journey...
|
||||||
<a
|
</p>
|
||||||
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"
|
{/* Inspirational quote */}
|
||||||
>
|
<div className="max-w-xs mx-auto">
|
||||||
Templates
|
<blockquote className="text-gray-300 italic text-sm leading-relaxed">
|
||||||
</a>{" "}
|
"Be anxious for nothing, but in everything by prayer and supplication, with thanksgiving, let your requests be made known to God."
|
||||||
or the{" "}
|
</blockquote>
|
||||||
<a
|
<cite className="text-gray-500 text-xs mt-2 block">
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
— Philippians 4:6
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
</cite>
|
||||||
>
|
</div>
|
||||||
Learning
|
|
||||||
</a>{" "}
|
{/* Progress dots */}
|
||||||
center.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<div className="flex gap-4">
|
||||||
<a
|
{isAdmin && (
|
||||||
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]"
|
<button
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
onClick={() => setShowAdmin(!showAdmin)}
|
||||||
target="_blank"
|
className="px-4 py-3 bg-purple-600 hover:bg-purple-700 rounded text-base font-medium"
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
>
|
{showAdmin ? 'Hide Admin' : 'Admin'}
|
||||||
<Image
|
</button>
|
||||||
className="dark:invert"
|
)}
|
||||||
src="/vercel.svg"
|
<button onClick={() => logout()} className="px-4 py-3 bg-red-600 hover:bg-red-700 rounded text-base font-medium">
|
||||||
alt="Vercel logomark"
|
Logout
|
||||||
width={16}
|
</button>
|
||||||
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>
|
</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>
|
</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);
|
||||||
@@ -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
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -19,7 +23,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -28,7 +34,11 @@
|
|||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts",
|
||||||
|
".next\\dev/types/**/*.ts",
|
||||||
|
".next\\dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user