commit c033410c2eb61bc63ff385937695a47eb634bf6a Author: Ryderjj89 Date: Mon Sep 29 18:59:31 2025 -0400 Complete sermon management system with Nuxt 4, authentication, SQLite database, QR codes, and Docker deployment diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd71f3b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:18-alpine + +# Install dumb-init for proper signal handling +RUN apk add --no-cache dumb-init + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy application code +COPY . . + +# Build the application +RUN npm run build + +# Create data directory for SQLite +RUN mkdir -p /data + +# Set proper permissions +RUN chown -R node:node /app /data + +# Switch to non-root user +USER node + +# Expose port +EXPOSE 3000 + +# Use dumb-init to handle signals properly +ENTRYPOINT ["/usr/bin/dumb-init", "--"] + +# Start the application +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c93c811 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# New Life Christian Church Sermon Management System + +A Nuxt 4 application for managing and displaying church sermons with authentication and QR code generation. + +## Features + +- **Sermon Management**: Create, view, and manage sermons with Bible references, personal applications, and pastor's challenges +- **Authentication**: Secure admin login for sermon creation +- **Responsive Design**: Built with NuxtUI for a modern, mobile-friendly interface +- **QR Codes**: Auto-generated QR codes for each sermon linking to the sermon page +- **SQLite Database**: Lightweight, file-based database for sermon storage +- **Docker Ready**: Complete containerization for easy deployment + +## Quick Start + +1. **Clone and setup**: + ```bash + git clone + cd nlcc-sermons + npm install + ``` + +2. **Run locally**: + ```bash + npm run dev + ``` + +3. **Build for production**: + ```bash + npm run build + ``` + +4. **Deploy with Docker**: + ```bash + docker-compose up -d + ``` + +## Environment Variables + +Create a `.env` file or set these environment variables: + +- `JWT_SECRET`: Secret key for JWT token generation (change in production!) +- `ADMIN_PASSWORD`: Default admin password (default: admin123) + +## Default Login + +- **Username**: admin +- **Password**: admin123 (or set via ADMIN_PASSWORD) + +## Project Structure + +``` +├── server/ +│ ├── api/ # API routes +│ │ ├── auth/ # Authentication endpoints +│ │ └── sermons/ # Sermon management endpoints +│ └── utils/ # Database and auth utilities +├── components/ # Vue components +├── pages/ # Nuxt pages +├── public/ # Static assets +└── data/ # SQLite database location +``` + +## API Endpoints + +- `POST /api/auth/login` - User authentication +- `GET /api/sermons` - List sermons (with time filtering) +- `POST /api/sermons` - Create new sermon (admin only) +- `GET /api/sermons/[slug]` - Get specific sermon + +## Docker Deployment + +The application is fully containerized and ready for deployment: + +```bash +# Build and run +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop +docker-compose down +``` + +The SQLite database will persist in a Docker volume named `sermon_data`. + +## Development + +- Uses Nuxt 4 with NuxtUI components +- TypeScript for type safety +- Tailwind CSS for styling +- QR code generation for sermon sharing +- Responsive design for all devices + +## Security Notes + +- Change default passwords before production deployment +- Use strong JWT secrets in production +- Consider implementing rate limiting for API endpoints +- Database is stored in `/data/sermons.db` in the container diff --git a/app.vue b/app.vue new file mode 100644 index 0000000..f5e850d --- /dev/null +++ b/app.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/assets/css/main.css b/assets/css/main.css new file mode 100644 index 0000000..780e889 --- /dev/null +++ b/assets/css/main.css @@ -0,0 +1,18 @@ +/* Inter font from Google Fonts - Clean, modern sans-serif */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +/* Apply Inter font family to the entire application */ +html { + font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +/* Ensure consistent font application */ +body { + font-family: inherit; + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; +} + +/* Custom font classes if needed */ +.font-inter { + font-family: 'Inter', ui-sans-serif, system-ui, sans-serif; +} diff --git a/components/LoginModal.vue b/components/LoginModal.vue new file mode 100644 index 0000000..ab371b5 --- /dev/null +++ b/components/LoginModal.vue @@ -0,0 +1,112 @@ + + + diff --git a/components/QRCodeButton.vue b/components/QRCodeButton.vue new file mode 100644 index 0000000..672eea6 --- /dev/null +++ b/components/QRCodeButton.vue @@ -0,0 +1,38 @@ + + + diff --git a/components/QRCodeModal.vue b/components/QRCodeModal.vue new file mode 100644 index 0000000..919051a --- /dev/null +++ b/components/QRCodeModal.vue @@ -0,0 +1,95 @@ + + + diff --git a/components/SermonCard.vue b/components/SermonCard.vue new file mode 100644 index 0000000..200846b --- /dev/null +++ b/components/SermonCard.vue @@ -0,0 +1,93 @@ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d36d80 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + sermons: + build: . + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - JWT_SECRET=your-super-secret-jwt-key-change-this-in-production + - ADMIN_PASSWORD=admin123 + volumes: + - sermon_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"] + interval: 30s + retries: 3 + start_period: 40s + timeout: 10s + +volumes: + sermon_data: + driver: local diff --git a/nuxt.config.ts b/nuxt.config.ts new file mode 100644 index 0000000..2d76c6a --- /dev/null +++ b/nuxt.config.ts @@ -0,0 +1,44 @@ +export default defineNuxtConfig({ + devtools: { enabled: false }, + modules: [ + '@nuxt/ui', + '@nuxtjs/tailwindcss' + ], + ui: { + theme: { + colors: { + primary: 'red', + gray: 'slate' + } + } + }, + runtimeConfig: { + jwtSecret: process.env.JWT_SECRET || 'your-secret-key', + adminPassword: process.env.ADMIN_PASSWORD || 'admin123' + }, + nitro: { + experimental: { + wasm: true + } + }, + css: ['~/assets/css/main.css'], + app: { + head: { + link: [ + { + rel: 'preconnect', + href: 'https://fonts.googleapis.com' + }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossorigin: '' + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap' + } + ] + } + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..2cbe23a --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "nlcc-sermons", + "version": "1.0.0", + "description": "New Life Christian Church Sermon Management System", + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare" + }, + "dependencies": { + "nuxt": "^4.0.0", + "@nuxt/ui": "^4.0.0", + "@nuxtjs/tailwindcss": "^6.12.0", + "better-sqlite3": "^11.0.0", + "qrcode": "^1.5.3", + "jose": "^5.2.0", + "date-fns": "^3.6.0", + "bcrypt": "^5.1.1" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@types/better-sqlite3": "^7.6.8", + "@types/qrcode": "^1.5.5" + } +} diff --git a/pages/[slug].vue b/pages/[slug].vue new file mode 100644 index 0000000..c91df6e --- /dev/null +++ b/pages/[slug].vue @@ -0,0 +1,154 @@ + + + diff --git a/pages/admin.vue b/pages/admin.vue new file mode 100644 index 0000000..76acd37 --- /dev/null +++ b/pages/admin.vue @@ -0,0 +1,241 @@ + + + diff --git a/pages/index.vue b/pages/index.vue new file mode 100644 index 0000000..a3a1003 --- /dev/null +++ b/pages/index.vue @@ -0,0 +1,141 @@ + + + diff --git a/pages/logos/logo.png b/pages/logos/logo.png new file mode 100644 index 0000000..c9840e1 Binary files /dev/null and b/pages/logos/logo.png differ diff --git a/public/fonts/README.md b/public/fonts/README.md new file mode 100644 index 0000000..c169cd0 --- /dev/null +++ b/public/fonts/README.md @@ -0,0 +1,33 @@ +# Neue Haas Grotesk Font Setup + +If you have the Neue Haas Grotesk font files, place them in this directory with the following naming convention: + +## Required Font Files: + +- `neue-haas-grotesk-regular.woff2` (and `.woff`) +- `neue-haas-grotesk-medium.woff2` (and `.woff`) - for 500 weight +- `neue-haas-grotesk-bold.woff2` (and `.woff`) - for 700 weight + +## Alternative Setup: + +If you don't have the Neue Haas Grotesk font files, the application will automatically fall back to using Inter font from Google Fonts, which is a similar clean, modern sans-serif font. + +## Current Font Stack: + +The application uses the following font fallback order: +1. Inter (Google Fonts) - Primary fallback +2. Neue Haas Grotesk (if font files are present) +3. System UI fonts (system-ui, -apple-system, etc.) +4. Generic sans-serif + +## To Enable Neue Haas Grotesk: + +1. Add your font files to this `/public/fonts/` directory +2. Uncomment the `@font-face` declarations in `assets/css/main.css` +3. The font will automatically be used throughout the application + +## Font Weights Used: + +- Regular (400): Body text, general content +- Medium (500): Subheadings, important information +- Bold (700): Headings, buttons, emphasis diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts new file mode 100644 index 0000000..2c502e8 --- /dev/null +++ b/server/api/auth/login.post.ts @@ -0,0 +1,37 @@ +import { authenticateUser, createJWT } from '~/server/utils/auth' + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const { username, password } = body + + if (!username || !password) { + throw createError({ + statusCode: 400, + statusMessage: 'Username and password are required' + }) + } + + const user = await authenticateUser(username, password) + if (!user) { + throw createError({ + statusCode: 401, + statusMessage: 'Invalid credentials' + }) + } + + const token = await createJWT(user) + + setCookie(event, 'auth_token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 // 7 days + }) + + return { + user: { + id: user.id, + username: user.username + } + } +}) diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts new file mode 100644 index 0000000..0029bad --- /dev/null +++ b/server/api/auth/logout.post.ts @@ -0,0 +1,6 @@ +export default defineEventHandler(async (event) => { + // Clear the auth cookie + deleteCookie(event, 'auth_token') + + return { success: true } +}) diff --git a/server/api/auth/verify.get.ts b/server/api/auth/verify.get.ts new file mode 100644 index 0000000..cd1d416 --- /dev/null +++ b/server/api/auth/verify.get.ts @@ -0,0 +1,27 @@ +import { verifyJWT } from '~/server/utils/auth' + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') + + if (!token) { + throw createError({ + statusCode: 401, + statusMessage: 'No authentication token provided' + }) + } + + const payload = await verifyJWT(token) + if (!payload) { + throw createError({ + statusCode: 401, + statusMessage: 'Invalid authentication token' + }) + } + + return { + user: { + id: payload.userId, + username: payload.username + } + } +}) diff --git a/server/api/sermons/[slug].get.ts b/server/api/sermons/[slug].get.ts new file mode 100644 index 0000000..635316d --- /dev/null +++ b/server/api/sermons/[slug].get.ts @@ -0,0 +1,28 @@ +import { getDatabase } from '~/server/utils/database' + +export default defineEventHandler(async (event) => { + const db = await getDatabase() + const slug = getRouterParam(event, 'slug') + + if (!slug) { + throw createError({ + statusCode: 400, + statusMessage: 'Sermon slug is required' + }) + } + + const sermon = db.prepare('SELECT * FROM sermons WHERE slug = ?').get(slug) as any + + if (!sermon) { + throw createError({ + statusCode: 404, + statusMessage: 'Sermon not found' + }) + } + + // Parse JSON fields + return { + ...sermon, + bibleReferences: sermon.bible_references ? JSON.parse(sermon.bible_references) : [] + } +}) diff --git a/server/api/sermons/index.get.ts b/server/api/sermons/index.get.ts new file mode 100644 index 0000000..e096c1f --- /dev/null +++ b/server/api/sermons/index.get.ts @@ -0,0 +1,39 @@ +import { getDatabase } from '~/server/utils/database' +import { verifyJWT } from '~/server/utils/auth' + +export default defineEventHandler(async (event) => { + const db = await getDatabase() + + // Check for time filter + const query = getQuery(event) + const timeFilter = query.time as string || '3months' + + let dateFilter = '' + const now = new Date() + + switch (timeFilter) { + case '3months': + const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate()) + dateFilter = threeMonthsAgo.toISOString() + break + case '6months': + const sixMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate()) + dateFilter = sixMonthsAgo.toISOString() + break + case '1year': + const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()) + dateFilter = oneYearAgo.toISOString() + break + case 'all': + dateFilter = '1970-01-01' + break + } + + const sermons = db.prepare(` + SELECT * FROM sermons + WHERE date >= ? + ORDER BY date DESC + `).all(dateFilter) as any[] + + return sermons +}) diff --git a/server/api/sermons/index.post.ts b/server/api/sermons/index.post.ts new file mode 100644 index 0000000..aca0908 --- /dev/null +++ b/server/api/sermons/index.post.ts @@ -0,0 +1,43 @@ +import { getDatabase } from '~/server/utils/database' +import { verifyJWT, generateSlug } from '~/server/utils/auth' + +export default defineEventHandler(async (event) => { + const db = await getDatabase() + + const body = await readBody(event) + const { title, date, bibleReferences, personalApplication, pastorChallenge } = body + + if (!title || !date) { + throw createError({ + statusCode: 400, + statusMessage: 'Title and date are required' + }) + } + + const slug = generateSlug(title, date) + + try { + const result = db.prepare(` + INSERT INTO sermons (title, date, slug, bible_references, personal_application, pastor_challenge) + VALUES (?, ?, ?, ?, ?, ?) + `).run(title, date, slug, JSON.stringify(bibleReferences || []), personalApplication || '', pastorChallenge || '') + + return { + id: result.lastInsertRowid, + title, + date, + slug, + bibleReferences: bibleReferences || [], + personalApplication: personalApplication || '', + pastorChallenge: pastorChallenge || '' + } + } catch (error: any) { + if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { + throw createError({ + statusCode: 409, + statusMessage: 'A sermon with this date already exists' + }) + } + throw error + } +}) diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 0000000..12bd32b --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,64 @@ +import bcrypt from 'bcrypt' +import { SignJWT, jwtVerify } from 'jose' +import { getDatabase } from './database' + +export interface User { + id: number + username: string + password_hash: string + created_at: string +} + +export async function authenticateUser(username: string, password: string): Promise { + const db = await getDatabase() + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined + + if (!user) return null + + const isValid = await bcrypt.compare(password, user.password_hash) + if (!isValid) return null + + return user +} + +export async function createJWT(user: User): Promise { + const config = useRuntimeConfig() + const secret = new TextEncoder().encode(config.jwtSecret) + + return await new SignJWT({ userId: user.id, username: user.username }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('7d') + .sign(secret) +} + +export async function verifyJWT(token: string): Promise<{ userId: number; username: string } | null> { + try { + const config = useRuntimeConfig() + const secret = new TextEncoder().encode(config.jwtSecret) + + const { payload } = await jwtVerify(token, secret) + return { + userId: payload.userId as number, + username: payload.username as string + } + } catch { + return null + } +} + +export function generateSlug(title: string, date: string): string { + const formattedTitle = title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim() + + const dateObj = new Date(date) + const month = String(dateObj.getMonth() + 1).padStart(2, '0') + const day = String(dateObj.getDate()).padStart(2, '0') + const year = dateObj.getFullYear() + + return `sermon-${month}${day}${year}` +} diff --git a/server/utils/database.ts b/server/utils/database.ts new file mode 100644 index 0000000..2f15189 --- /dev/null +++ b/server/utils/database.ts @@ -0,0 +1,60 @@ +import Database from 'better-sqlite3' +import { join } from 'path' +import { existsSync, mkdirSync } from 'fs' +import { dirname } from 'path' + +let db: Database.Database + +export async function getDatabase() { + if (!db) { + const dbPath = process.env.NODE_ENV === 'production' + ? '/data/sermons.db' + : './data/sermons.db' + + // Ensure directory exists + const dir = dirname(dbPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + db = new Database(dbPath) + await initializeDatabase(db) + } + return db +} + +async function initializeDatabase(db: Database.Database) { + // Create sermons table + db.exec(` + CREATE TABLE IF NOT EXISTS sermons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + date TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + bible_references TEXT, + personal_application TEXT, + pastor_challenge TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + + // Create users table for authentication + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + + // Note: Admin user creation is handled in auth.ts to avoid circular dependencies +} + +export function closeDatabase() { + if (db) { + db.close() + db = null + } +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..2d3d73f --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,34 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./components/**/*.{js,vue,ts}", + "./layouts/**/*.vue", + "./pages/**/*.vue", + "./plugins/**/*.{js,ts}", + "./nuxt.config.{js,ts}", + "./app.vue" + ], + theme: { + extend: { + fontFamily: { + 'sans': ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', '"Noto Sans"', 'sans-serif', '"Apple Color Emoji"', '"Segoe UI Emoji"', '"Segoe UI Symbol"', '"Noto Color Emoji"'] + }, + colors: { + primary: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + 950: '#450a0a', + } + } + }, + }, + plugins: [], +}