Complete sermon itinerary application with Nuxt 3, SQLite, authentication, and Docker deployment

This commit is contained in:
2025-10-01 22:15:01 -04:00
parent dacaea6fa4
commit 1b282c05fe
26 changed files with 1245 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.nuxt
.output
.git
.gitignore
README.md
.env
.env.*
data/*.db

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# Database
data/
*.db
*.db-shm
*.db-wal

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy application files
COPY . .
# Create data directory for SQLite database
RUN mkdir -p /app/data
# Build the application
RUN npm run build
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
# Start the application
CMD ["node", ".output/server/index.mjs"]

167
README.md Normal file
View File

@@ -0,0 +1,167 @@
# New Life Christian Church - Sermon Itinerary
A web application for managing and displaying weekly sermons for New Life Christian Church.
## Features
- 📝 **Sermon Management**: Create and manage sermon content with a user-friendly form
- 🔐 **Authentication**: Secure admin access with login system
- 📱 **QR Codes**: Generate QR codes for easy sermon sharing
- 📅 **Date-based URLs**: Sermons accessible via `sermon-MMDDYYYY` format
- 🎨 **Modern UI**: Clean, responsive design using Tailwind CSS and Inter font
- 📊 **Three Sections**: Bible References, Personal Appliance, and Pastor's Challenge
- 🗂️ **Smart Organization**: Recent sermons (last 3 months) displayed by default, older sermons in dropdown
- 🐳 **Docker Ready**: Fully containerized for easy deployment
## Technology Stack
- **Frontend**: Nuxt 3 (Vue.js)
- **Styling**: Tailwind CSS with Inter font from Google Fonts
- **Database**: SQLite
- **QR Codes**: qrcode library
- **Deployment**: Docker & Docker Compose
## Getting Started
### Prerequisites
- Docker and Docker Compose installed on your system
### Installation & Deployment
1. Clone the repository:
```bash
git clone <repository-url>
cd nlcc-itinerary
```
2. Update environment variables in `docker-compose.yml`:
```yaml
environment:
- AUTH_SECRET=your-secret-key-change-in-production
- SITE_URL=https://newlife-christian.com
```
3. Build and run with Docker Compose:
```bash
docker-compose up -d
```
The application will be available at `http://localhost:3000`
### Default Credentials
- **Username**: admin
- **Password**: admin123
⚠️ **Important**: Change these credentials in production by modifying `server/utils/database.ts`
## Project Structure
```
nlcc-itinerary/
├── assets/css/ # Global styles
├── components/ # Vue components
│ ├── SermonCard.vue
│ ├── QRCodeButton.vue
│ └── QRCodeModal.vue
├── middleware/ # Route middleware
│ └── auth.ts
├── pages/ # Application pages
│ ├── index.vue # Main sermon listing
│ ├── login.vue # Admin login
│ ├── admin.vue # Sermon creation form
│ └── [slug].vue # Individual sermon page
├── server/
│ ├── api/ # API endpoints
│ │ ├── auth/ # Authentication endpoints
│ │ └── sermons/ # Sermon CRUD endpoints
│ └── utils/ # Server utilities
│ ├── database.ts # SQLite database functions
│ └── auth.ts # Authentication helpers
├── logos/ # Church logos
├── Dockerfile # Docker configuration
├── docker-compose.yml # Docker Compose configuration
└── nuxt.config.ts # Nuxt configuration
```
## Usage
### Creating a Sermon
1. Navigate to `/login` and sign in with admin credentials
2. You'll be redirected to `/admin`
3. Fill in the sermon details:
- **Date**: Select the sermon date (URL will be auto-generated as `sermon-MMDDYYYY`)
- **Title**: Enter the sermon title
- **Bible References**: Add one or more Bible verses (use +/- buttons)
- **Personal Appliance**: Enter personal application content
- **Pastor's Challenge**: Enter the pastor's challenge content
4. Click "Create Sermon"
### Viewing Sermons
- **Main Page** (`/`): Shows recent sermons (last 3 months) with option to view older ones
- **Individual Sermon** (`/sermon-MMDDYYYY`): Full sermon details with QR code
- **QR Code**: Click the QR code button on any sermon card or page to generate a scannable code
## Database
The application uses SQLite with the following schema:
### Sermons Table
- `id`: Primary key
- `slug`: Unique sermon identifier (e.g., sermon-09282025)
- `title`: Sermon title
- `date`: Sermon date
- `bible_references`: Newline-separated Bible verses
- `personal_appliance`: Personal application content
- `pastors_challenge`: Pastor's challenge content
- `created_at`: Timestamp
### Users Table
- `id`: Primary key
- `username`: User's username
- `password`: User's password (plain text - should be hashed in production)
## Security Notes
⚠️ **For Production Use**:
1. Change the default admin credentials
2. Implement proper password hashing (bcrypt, argon2, etc.)
3. Use a strong `AUTH_SECRET` in environment variables
4. Enable HTTPS
5. Consider implementing rate limiting
6. Add CSRF protection
## Docker Commands
```bash
# Build and start
docker-compose up -d
# View logs
docker-compose logs -f
# Stop containers
docker-compose down
# Rebuild after changes
docker-compose up -d --build
# Access container shell
docker exec -it nlcc-itinerary sh
```
## Data Persistence
The SQLite database is stored in the `./data` directory, which is mounted as a volume in Docker. This ensures sermon data persists across container restarts.
## License
This project is created for New Life Christian Church.
## Support
For issues or questions, please contact the development team.

5
app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<div>
<NuxtPage />
</div>
</template>

11
assets/css/main.css Normal file
View File

@@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
font-family: 'Inter', sans-serif;
}
body {
@apply bg-gray-50;
}

View File

@@ -0,0 +1,35 @@
<template>
<button
@click="showModal = true"
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
QR Code
</button>
<QRCodeModal
v-if="showModal"
:sermon="sermon"
@close="showModal = false"
/>
</template>
<script setup lang="ts">
interface Sermon {
id?: number
slug: string
title: string
date: string
bible_references: string
personal_appliance: string
pastors_challenge: string
}
defineProps<{
sermon: Sermon
}>()
const showModal = ref(false)
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" @click.self="$emit('close')">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Scan to View Sermon</h3>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex flex-col items-center">
<div class="bg-white p-4 rounded-lg border-2 border-gray-200">
<canvas ref="qrCanvas"></canvas>
</div>
<p class="mt-4 text-sm text-gray-600 text-center">{{ sermon.title }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import QRCode from 'qrcode'
interface Sermon {
id?: number
slug: string
title: string
date: string
bible_references: string
personal_appliance: string
pastors_challenge: string
}
const props = defineProps<{
sermon: Sermon
}>()
defineEmits<{
close: []
}>()
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const config = useRuntimeConfig()
onMounted(async () => {
if (qrCanvas.value) {
const url = `${config.public.siteUrl}/${props.sermon.slug}`
await QRCode.toCanvas(qrCanvas.value, url, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
}
})
</script>

44
components/SermonCard.vue Normal file
View File

@@ -0,0 +1,44 @@
<template>
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow overflow-hidden">
<div class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-2">{{ sermon.title }}</h3>
<p class="text-sm text-gray-500 mb-4">{{ formatDate(sermon.date) }}</p>
<div class="flex items-center justify-between">
<NuxtLink
:to="`/${sermon.slug}`"
class="text-blue-600 hover:text-blue-700 font-medium text-sm"
>
View Sermon
</NuxtLink>
<QRCodeButton :sermon="sermon" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Sermon {
id?: number
slug: string
title: string
date: string
bible_references: string
personal_appliance: string
pastors_challenge: string
}
defineProps<{
sermon: Sermon
}>()
function formatDate(dateString: string) {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
</script>

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
nlcc-itinerary:
build: .
container_name: nlcc-itinerary
ports:
- "3002:3000"
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- AUTH_SECRET=d8c7c1735fc853b807c1bccce791b054
- SITE_URL=https://nlcc.rydertech.us
restart: unless-stopped

9
middleware/auth.ts Normal file
View File

@@ -0,0 +1,9 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
if (import.meta.server) return
const { data } = await useFetch('/api/auth/verify')
if (!data.value?.authenticated) {
return navigateTo('/login')
}
})

36
nuxt.config.ts Normal file
View File

@@ -0,0 +1,36 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-04-03',
devtools: { enabled: true },
modules: [
'@nuxtjs/tailwindcss',
'@nuxt/ui'
],
app: {
head: {
title: 'New Life Christian Church - Sermons',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'Weekly sermons from New Life Christian Church' }
],
link: [
{ rel: 'icon', type: 'image/png', href: '/logos/favicon.png' },
{ 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@300;400;500;600;700&display=swap' }
]
}
},
css: ['~/assets/css/main.css'],
runtimeConfig: {
authSecret: process.env.AUTH_SECRET || 'change-this-secret-in-production',
public: {
siteUrl: process.env.SITE_URL || 'https://newlife-christian.com'
}
}
})

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "nlcc-itinerary",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/ui": "^2.18.4",
"better-sqlite3": "^11.3.0",
"nuxt": "^3.13.2",
"qrcode": "^1.5.4",
"vue": "^3.5.5",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.12.1",
"@types/better-sqlite3": "^7.6.11",
"@types/qrcode": "^1.5.5"
}
}

106
pages/[slug].vue Normal file
View File

@@ -0,0 +1,106 @@
<template>
<div class="min-h-screen bg-gray-50">
<header class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<NuxtLink to="/" class="flex items-center space-x-4 hover:opacity-80">
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-16 w-auto" />
</NuxtLink>
<QRCodeButton v-if="sermon" :sermon="sermon" />
</div>
</div>
</header>
<main class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div v-if="sermon" class="bg-white shadow-lg rounded-lg p-8">
<!-- Header -->
<div class="border-b pb-6 mb-8">
<h1 class="text-4xl font-bold text-gray-900 mb-2">{{ sermon.title }}</h1>
<p class="text-lg text-gray-600">{{ formatDate(sermon.date) }}</p>
</div>
<!-- Section 1: Bible References -->
<section class="mb-8">
<h2 class="text-2xl font-semibold text-gray-900 mb-4">Bible References</h2>
<div class="bg-blue-50 rounded-lg p-6">
<ul class="space-y-2">
<li
v-for="(ref, index) in bibleReferences"
:key="index"
class="text-gray-800 flex items-start"
>
<span class="text-blue-600 mr-2"></span>
<span>{{ ref }}</span>
</li>
</ul>
</div>
</section>
<!-- Section 2: Personal Appliance -->
<section class="mb-8">
<h2 class="text-2xl font-semibold text-gray-900 mb-4">Personal Appliance</h2>
<div class="bg-green-50 rounded-lg p-6">
<p class="text-gray-800 whitespace-pre-wrap">{{ sermon.personal_appliance }}</p>
</div>
</section>
<!-- Section 3: Pastor's Challenge -->
<section class="mb-8">
<h2 class="text-2xl font-semibold text-gray-900 mb-4">Pastor's Challenge</h2>
<div class="bg-purple-50 rounded-lg p-6">
<p class="text-gray-800 whitespace-pre-wrap">{{ sermon.pastors_challenge }}</p>
</div>
</section>
<!-- Back Button -->
<div class="border-t pt-6">
<NuxtLink
to="/"
class="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to All Sermons
</NuxtLink>
</div>
</div>
<!-- Error State -->
<div v-else class="bg-white shadow-lg rounded-lg p-8 text-center">
<h2 class="text-2xl font-semibold text-gray-900 mb-4">Sermon Not Found</h2>
<p class="text-gray-600 mb-6">The sermon you're looking for doesn't exist.</p>
<NuxtLink
to="/"
class="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to All Sermons
</NuxtLink>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug as string
const { data: sermon } = await useFetch(`/api/sermons/${slug}`)
const bibleReferences = computed(() => {
if (!sermon.value?.bible_references) return []
return sermon.value.bible_references.split('\n').filter((ref: string) => ref.trim())
})
function formatDate(dateString: string) {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
</script>

212
pages/admin.vue Normal file
View File

@@ -0,0 +1,212 @@
<template>
<div class="min-h-screen bg-gray-50">
<header class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-16 w-auto" />
<h1 class="text-2xl font-bold text-gray-900">Create New Sermon</h1>
</div>
<button
@click="handleLogout"
class="text-sm font-medium text-red-600 hover:text-red-700"
>
Logout
</button>
</div>
</div>
</header>
<main class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<form @submit.prevent="handleSubmit" class="bg-white shadow-lg rounded-lg p-8 space-y-8">
<!-- Basic Info -->
<div class="space-y-6">
<div>
<label for="date" class="block text-sm font-medium text-gray-700 mb-2">
Sermon Date
</label>
<input
id="date"
v-model="formData.date"
type="date"
required
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
Sermon Title
</label>
<input
id="title"
v-model="formData.title"
type="text"
required
placeholder="Enter sermon title"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<!-- Section 1: Bible References -->
<div class="border-t pt-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Section 1: Bible References</h2>
<div class="space-y-3">
<div v-for="(ref, index) in bibleReferences" :key="index" class="flex gap-2">
<input
v-model="bibleReferences[index]"
type="text"
placeholder="e.g., John 3:16"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
<button
v-if="bibleReferences.length > 1"
type="button"
@click="removeReference(index)"
class="px-3 py-2 bg-red-100 text-red-700 rounded-md hover:bg-red-200"
>
</button>
</div>
<button
type="button"
@click="addReference"
class="px-4 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 font-medium"
>
+ Add Reference
</button>
</div>
</div>
<!-- Section 2: Personal Appliance -->
<div class="border-t pt-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Section 2: Personal Appliance</h2>
<textarea
v-model="formData.personal_appliance"
rows="6"
required
placeholder="Enter personal appliance content..."
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
></textarea>
</div>
<!-- Section 3: Pastor's Challenge -->
<div class="border-t pt-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Section 3: Pastor's Challenge</h2>
<textarea
v-model="formData.pastors_challenge"
rows="6"
required
placeholder="Enter pastor's challenge content..."
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
></textarea>
</div>
<!-- Error/Success Messages -->
<div v-if="error" class="text-red-600 text-sm">
{{ error }}
</div>
<div v-if="success" class="text-green-600 text-sm">
{{ success }}
</div>
<!-- Submit Button -->
<div class="flex gap-4">
<button
type="submit"
:disabled="loading"
class="flex-1 py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{{ loading ? 'Creating...' : 'Create Sermon' }}
</button>
<NuxtLink
to="/"
class="px-6 py-3 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</NuxtLink>
</div>
</form>
</main>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
const bibleReferences = ref([''])
const formData = ref({
date: '',
title: '',
personal_appliance: '',
pastors_challenge: ''
})
const error = ref('')
const success = ref('')
const loading = ref(false)
function addReference() {
bibleReferences.value.push('')
}
function removeReference(index: number) {
bibleReferences.value.splice(index, 1)
}
function formatDateToSlug(date: string) {
// Convert YYYY-MM-DD to MMDDYYYY
const [year, month, day] = date.split('-')
return `sermon-${month}${day}${year}`
}
async function handleSubmit() {
error.value = ''
success.value = ''
loading.value = true
try {
const slug = formatDateToSlug(formData.value.date)
const bible_references = bibleReferences.value.filter(ref => ref.trim()).join('\n')
await $fetch('/api/sermons', {
method: 'POST',
body: {
slug,
title: formData.value.title,
date: formData.value.date,
bible_references,
personal_appliance: formData.value.personal_appliance,
pastors_challenge: formData.value.pastors_challenge
}
})
success.value = 'Sermon created successfully!'
// Reset form
formData.value = {
date: '',
title: '',
personal_appliance: '',
pastors_challenge: ''
}
bibleReferences.value = ['']
// Redirect after 2 seconds
setTimeout(() => {
navigateTo('/')
}, 2000)
} catch (e: any) {
error.value = e.data?.message || 'Failed to create sermon'
} finally {
loading.value = false
}
}
async function handleLogout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/login')
}
</script>

99
pages/index.vue Normal file
View File

@@ -0,0 +1,99 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-16 w-auto" />
</div>
<NuxtLink
to="/login"
class="text-sm font-medium text-blue-600 hover:text-blue-700"
>
Admin Login
</NuxtLink>
</div>
<div class="text-center mt-6">
<h1 class="text-3xl font-bold text-gray-900">
Welcome to New Life! Please choose the sermon you'd like to see.
</h1>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Recent Sermons (Last 3 months) -->
<div v-if="recentSermons.length > 0">
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Recent Sermons</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
<SermonCard
v-for="sermon in recentSermons"
:key="sermon.id"
:sermon="sermon"
/>
</div>
</div>
<!-- Older Sermons Dropdown -->
<div v-if="olderSermons.length > 0" class="mt-8">
<button
@click="showOlder = !showOlder"
class="w-full bg-white border border-gray-300 rounded-lg px-6 py-4 text-left hover:bg-gray-50 transition-colors flex items-center justify-between"
>
<span class="text-lg font-medium text-gray-900">View Older Sermons</span>
<svg
:class="['w-5 h-5 text-gray-500 transition-transform', showOlder ? 'rotate-180' : '']"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div v-if="showOlder" class="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<SermonCard
v-for="sermon in olderSermons"
:key="sermon.id"
:sermon="sermon"
/>
</div>
</div>
<!-- Empty State -->
<div v-if="recentSermons.length === 0 && olderSermons.length === 0" class="text-center py-12">
<p class="text-gray-500 text-lg">No sermons available yet.</p>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const showOlder = ref(false)
// Fetch all sermons
const { data: allSermons } = await useFetch('/api/sermons')
// Calculate date 3 months ago
const threeMonthsAgo = new Date()
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3)
// Split sermons into recent and older
const recentSermons = computed(() => {
if (!allSermons.value) return []
return allSermons.value.filter((sermon: any) => {
const sermonDate = new Date(sermon.date)
return sermonDate >= threeMonthsAgo
})
})
const olderSermons = computed(() => {
if (!allSermons.value) return []
return allSermons.value.filter((sermon: any) => {
const sermonDate = new Date(sermon.date)
return sermonDate < threeMonthsAgo
})
})
</script>

93
pages/login.vue Normal file
View File

@@ -0,0 +1,93 @@
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div class="max-w-md w-full">
<div class="text-center mb-8">
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-20 w-auto mx-auto mb-4" />
<h2 class="text-3xl font-bold text-gray-900">Admin Login</h2>
<p class="mt-2 text-sm text-gray-600">Sign in to manage sermons</p>
</div>
<div class="bg-white py-8 px-6 shadow-lg rounded-lg">
<form @submit.prevent="handleLogin" class="space-y-6">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">
Username
</label>
<input
id="username"
v-model="username"
type="text"
required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
v-model="password"
type="password"
required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div v-if="error" class="text-red-600 text-sm">
{{ error }}
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{{ loading ? 'Signing in...' : 'Sign in' }}
</button>
</form>
<div class="mt-6 text-center">
<NuxtLink to="/" class="text-sm text-blue-600 hover:text-blue-700">
Back to Sermons
</NuxtLink>
</div>
</div>
<div class="mt-4 text-center text-sm text-gray-600">
<p>Default credentials: admin / admin123</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
error.value = ''
loading.value = true
try {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: {
username: username.value,
password: password.value
}
})
if (response.success) {
await navigateTo('/admin')
}
} catch (e: any) {
error.value = e.data?.message || 'Invalid credentials'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,30 @@
import { getUserByUsername } from '~/server/utils/database'
import { setAuthCookie } 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,
message: 'Username and password are required'
})
}
const user = getUserByUsername(username)
if (!user || user.password !== password) {
throw createError({
statusCode: 401,
message: 'Invalid credentials'
})
}
setAuthCookie(event, username)
return {
success: true,
username: user.username
}
})

View File

@@ -0,0 +1,9 @@
import { clearAuthCookie } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
clearAuthCookie(event)
return {
success: true
}
})

View File

@@ -0,0 +1,9 @@
import { isAuthenticated } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const authenticated = isAuthenticated(event)
return {
authenticated
}
})

View File

@@ -0,0 +1,23 @@
import { getSermonBySlug } from '~/server/utils/database'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({
statusCode: 400,
message: 'Slug is required'
})
}
const sermon = getSermonBySlug(slug)
if (!sermon) {
throw createError({
statusCode: 404,
message: 'Sermon not found'
})
}
return sermon
})

View File

@@ -0,0 +1,10 @@
import { getAllSermons } from '~/server/utils/database'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const limit = query.limit ? parseInt(query.limit as string) : undefined
const sermons = getAllSermons(limit)
return sermons
})

View File

@@ -0,0 +1,43 @@
import { createSermon } from '~/server/utils/database'
import { isAuthenticated } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
// Check authentication
if (!isAuthenticated(event)) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
})
}
const body = await readBody(event)
const { slug, title, date, bible_references, personal_appliance, pastors_challenge } = body
if (!slug || !title || !date || !bible_references || !personal_appliance || !pastors_challenge) {
throw createError({
statusCode: 400,
message: 'All fields are required'
})
}
try {
createSermon({
slug,
title,
date,
bible_references,
personal_appliance,
pastors_challenge
})
return {
success: true,
message: 'Sermon created successfully'
}
} catch (error: any) {
throw createError({
statusCode: 500,
message: error.message || 'Failed to create sermon'
})
}
})

23
server/utils/auth.ts Normal file
View File

@@ -0,0 +1,23 @@
import { H3Event } from 'h3'
export function setAuthCookie(event: H3Event, username: string) {
setCookie(event, 'auth', username, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/'
})
}
export function getAuthCookie(event: H3Event) {
return getCookie(event, 'auth')
}
export function clearAuthCookie(event: H3Event) {
deleteCookie(event, 'auth')
}
export function isAuthenticated(event: H3Event): boolean {
const auth = getAuthCookie(event)
return !!auth
}

93
server/utils/database.ts Normal file
View File

@@ -0,0 +1,93 @@
import Database from 'better-sqlite3'
import { join } from 'path'
let db: Database.Database | null = null
export interface Sermon {
id?: number
slug: string
title: string
date: string
bible_references: string
personal_appliance: string
pastors_challenge: string
created_at?: string
}
export interface User {
id?: number
username: string
password: string
}
export function getDatabase() {
if (!db) {
const dbPath = join(process.cwd(), 'data', 'sermons.db')
db = new Database(dbPath)
// Create tables if they don't exist
db.exec(`
CREATE TABLE IF NOT EXISTS sermons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
date TEXT NOT NULL,
bible_references TEXT NOT NULL,
personal_appliance TEXT NOT NULL,
pastors_challenge TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)
`)
// Insert default admin user (password: admin123)
// In production, this should be hashed properly
const userExists = db.prepare('SELECT COUNT(*) as count FROM users WHERE username = ?').get('admin') as { count: number }
if (userExists.count === 0) {
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('admin', 'admin123')
}
}
return db
}
export function getAllSermons(limit?: number) {
const db = getDatabase()
if (limit) {
return db.prepare('SELECT * FROM sermons ORDER BY date DESC LIMIT ?').all(limit) as Sermon[]
}
return db.prepare('SELECT * FROM sermons ORDER BY date DESC').all() as Sermon[]
}
export function getSermonBySlug(slug: string) {
const db = getDatabase()
return db.prepare('SELECT * FROM sermons WHERE slug = ?').get(slug) as Sermon | undefined
}
export function createSermon(sermon: Sermon) {
const db = getDatabase()
const stmt = db.prepare(`
INSERT INTO sermons (slug, title, date, bible_references, personal_appliance, pastors_challenge)
VALUES (?, ?, ?, ?, ?, ?)
`)
return stmt.run(
sermon.slug,
sermon.title,
sermon.date,
sermon.bible_references,
sermon.personal_appliance,
sermon.pastors_challenge
)
}
export function getUserByUsername(username: string) {
const db = getDatabase()
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined
}

18
tailwind.config.js Normal file
View File

@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./components/**/*.{js,vue,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./plugins/**/*.{js,ts}",
"./app.vue",
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
},
},
plugins: [],
}