Complete sermon management system with Nuxt 4, authentication, SQLite database, QR codes, and Docker deployment

This commit is contained in:
Ryderjj89
2025-09-29 18:59:31 -04:00
commit c033410c2e
25 changed files with 1510 additions and 0 deletions

37
Dockerfile Normal file
View File

@@ -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"]

101
README.md Normal file
View File

@@ -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 <repository-url>
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

13
app.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<div>
<NuxtPage />
</div>
</template>
<script setup lang="ts">
// Global app setup can go here
</script>
<style>
/* Global styles if needed */
</style>

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

@@ -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;
}

112
components/LoginModal.vue Normal file
View File

@@ -0,0 +1,112 @@
<template>
<UModal v-model="isOpen">
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Admin Login</h3>
</template>
<form @submit.prevent="handleSubmit" class="space-y-4">
<UFormGroup label="Username" name="username">
<UInput
v-model="form.username"
placeholder="Enter username"
:disabled="loading"
/>
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput
v-model="form.password"
type="password"
placeholder="Enter password"
:disabled="loading"
/>
</UFormGroup>
<div v-if="error" class="text-red-600 text-sm">
{{ error }}
</div>
<div class="flex justify-end space-x-2">
<UButton
type="button"
variant="outline"
:disabled="loading"
@click="close"
>
Cancel
</UButton>
<UButton
type="submit"
variant="solid"
color="primary"
:loading="loading"
>
Login
</UButton>
</div>
</form>
</UCard>
</UModal>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const form = reactive({
username: '',
password: ''
})
const loading = ref(false)
const error = ref('')
const handleSubmit = async () => {
if (!form.username || !form.password) {
error.value = 'Please enter both username and password'
return
}
loading.value = true
error.value = ''
try {
await $fetch('/api/auth/login', {
method: 'POST',
body: {
username: form.username,
password: form.password
}
})
emit('success')
close()
} catch (err: any) {
error.value = err.data?.statusMessage || 'Login failed'
} finally {
loading.value = false
}
}
const close = () => {
isOpen.value = false
form.username = ''
form.password = ''
error.value = ''
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<UButton
@click="showQRCode = true"
variant="ghost"
color="gray"
size="sm"
icon="i-heroicons-qr-code"
:aria-label="`Show QR code for ${title}`"
/>
</template>
<script setup lang="ts">
interface Props {
url: string
title?: string
}
const props = defineProps<Props>()
const showQRCode = ref(false)
const fullUrl = computed(() => {
if (process.client) {
return `${window.location.origin}${props.url}`
}
return props.url
})
// Emit event to parent component to show modal
const emit = defineEmits<{
click: [value: boolean]
}>()
watch(showQRCode, (open) => {
if (open) {
emit('click', open)
}
})
</script>

View File

@@ -0,0 +1,95 @@
<template>
<UModal v-model="isOpen">
<UCard class="max-w-md">
<template #header>
<h3 class="text-lg font-semibold">QR Code</h3>
<p class="text-sm text-gray-600 mt-1">{{ title }}</p>
</template>
<div class="text-center space-y-4">
<div class="bg-white p-4 rounded-lg inline-block">
<canvas ref="qrCanvas"></canvas>
</div>
<div class="space-y-2">
<p class="text-sm text-gray-600">
<strong>URL:</strong>
<span class="font-mono text-xs break-all">{{ fullUrl }}</span>
</p>
<UButton
@click="downloadQR"
variant="outline"
size="sm"
icon="i-heroicons-arrow-down-tray"
>
Download QR Code
</UButton>
</div>
</div>
</UCard>
</UModal>
</template>
<script setup lang="ts">
import QRCode from 'qrcode'
interface Props {
modelValue: boolean
url: string
title?: string
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const qrCanvas = ref<HTMLCanvasElement>()
const fullUrl = computed(() => {
if (process.client) {
return `${window.location.origin}${props.url}`
}
return props.url
})
const generateQR = async () => {
if (!qrCanvas.value) return
try {
await QRCode.toCanvas(qrCanvas.value, fullUrl.value, {
width: 200,
margin: 2,
color: {
dark: '#1f2937',
light: '#ffffff'
}
})
} catch (error) {
console.error('Failed to generate QR code:', error)
}
}
const downloadQR = () => {
if (!qrCanvas.value) return
const link = document.createElement('a')
link.download = `sermon-qr-${props.title || 'code'}.png`
link.href = qrCanvas.value.toDataURL()
link.click()
}
watch(isOpen, (open) => {
if (open) {
nextTick(() => {
generateQR()
})
}
})
</script>

93
components/SermonCard.vue Normal file
View File

@@ -0,0 +1,93 @@
<template>
<UCard class="h-full flex flex-col hover:shadow-lg transition-shadow duration-200">
<template #header>
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2">
{{ sermon.title }}
</h3>
<p class="text-sm text-gray-600 mt-1">
{{ formatDate(sermon.date) }}
</p>
</div>
<div class="ml-2">
<QRCodeButton :url="sermonUrl" :title="sermon.title" @click="showQRModal = $event" />
</div>
</div>
</template>
<div class="flex-1">
<div v-if="sermon.bibleReferences && sermon.bibleReferences.length > 0" class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Bible References:</h4>
<div class="space-y-1">
<p
v-for="reference in sermon.bibleReferences.slice(0, 3)"
:key="reference"
class="text-sm text-gray-600 bg-gray-50 px-2 py-1 rounded"
>
{{ reference }}
</p>
<p
v-if="sermon.bibleReferences.length > 3"
class="text-sm text-gray-500 italic"
>
+{{ sermon.bibleReferences.length - 3 }} more references
</p>
</div>
</div>
<div v-if="sermon.personalApplication" class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Personal Application:</h4>
<p class="text-sm text-gray-600 line-clamp-3">
{{ sermon.personalApplication }}
</p>
</div>
</div>
<template #footer>
<UButton
@click="navigateTo(sermonUrl)"
variant="outline"
color="primary"
class="w-full"
:label="`View Sermon - ${sermon.slug.replace('sermon-', '').replace(/(\d{2})(\d{2})(\d{4})/, '$1/$2/$3')}`"
/>
</template>
<!-- QR Code Modal -->
<QRCodeModal
v-model="showQRModal"
:url="sermonUrl"
:title="sermon.title"
/>
</UCard>
</template>
<script setup lang="ts">
import { format, parseISO } from 'date-fns'
interface Sermon {
id: number
title: string
date: string
slug: string
bibleReferences?: string[]
personalApplication?: string
pastorChallenge?: string
}
const props = defineProps<{
sermon: Sermon
}>()
const showQRModal = ref(false)
const sermonUrl = computed(() => `/${props.sermon.slug}`)
const formatDate = (dateString: string) => {
try {
return format(parseISO(dateString), 'MMMM d, yyyy')
} catch {
return dateString
}
}
</script>

24
docker-compose.yml Normal file
View File

@@ -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

44
nuxt.config.ts Normal file
View File

@@ -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'
}
]
}
}
})

28
package.json Normal file
View File

@@ -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"
}
}

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

@@ -0,0 +1,154 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-4">
<div class="flex items-center">
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-10 w-auto mr-4" />
<UButton
@click="navigateTo('/')"
variant="ghost"
color="gray"
icon="i-heroicons-arrow-left"
>
Back to Sermons
</UButton>
</div>
<div>
<QRCodeButton :url="currentUrl" :title="sermon?.title" @click="showQRModal = $event" />
</div>
</div>
</div>
</header>
<main class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Loading State -->
<div v-if="loading" class="flex justify-center py-12">
<UIcon name="i-heroicons-arrow-path" class="animate-spin h-8 w-8 text-primary" />
</div>
<!-- Sermon Content -->
<div v-else-if="sermon">
<UCard>
<template #header>
<div class="text-center">
<h1 class="text-3xl font-bold text-gray-900">{{ sermon.title }}</h1>
<p class="text-lg text-gray-600 mt-2">
{{ formatDate(sermon.date) }}
</p>
</div>
</template>
<div class="space-y-8">
<!-- Bible References -->
<div v-if="sermon.bibleReferences && sermon.bibleReferences.length > 0">
<h2 class="text-2xl font-semibold text-gray-900 mb-4">Bible References</h2>
<div class="grid gap-3 md:grid-cols-2">
<div
v-for="reference in sermon.bibleReferences"
:key="reference"
class="bg-red-50 border border-red-200 rounded-lg p-4"
>
<p class="text-red-800 font-medium">{{ reference }}</p>
</div>
</div>
</div>
<!-- Personal Application -->
<div v-if="sermon.personalApplication">
<h2 class="text-2xl font-semibold text-gray-900 mb-4">Personal Application</h2>
<UCard class="bg-blue-50 border-blue-200">
<p class="text-blue-900 whitespace-pre-wrap">{{ sermon.personalApplication }}</p>
</UCard>
</div>
<!-- Pastor's Challenge -->
<div v-if="sermon.pastorChallenge">
<h2 class="text-2xl font-semibold text-gray-900 mb-4">Pastor's Challenge</h2>
<UCard class="bg-green-50 border-green-200">
<p class="text-green-900 whitespace-pre-wrap">{{ sermon.pastorChallenge }}</p>
</UCard>
</div>
</div>
</UCard>
</div>
<!-- Not Found -->
<div v-else class="text-center py-12">
<UIcon name="i-heroicons-exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900">Sermon not found</h3>
<p class="mt-1 text-sm text-gray-500">
The sermon you're looking for doesn't exist.
</p>
<div class="mt-6">
<UButton @click="navigateTo('/')">
Back to Sermons
</UButton>
</div>
</div>
</main>
<!-- QR Code Modal -->
<QRCodeModal
v-model="showQRModal"
:url="currentUrl"
:title="sermon?.title"
/>
</div>
</template>
<script setup lang="ts">
import { format, parseISO } from 'date-fns'
interface Sermon {
id: number
title: string
date: string
slug: string
bibleReferences?: string[]
personalApplication?: string
pastorChallenge?: string
}
const route = useRoute()
const slug = route.params.slug as string
const sermon = ref<Sermon | null>(null)
const loading = ref(true)
const showQRModal = ref(false)
const currentUrl = computed(() => {
if (process.client) {
return window.location.pathname
}
return `/${slug}`
})
const formatDate = (dateString: string) => {
try {
return format(parseISO(dateString), 'MMMM d, yyyy')
} catch {
return dateString
}
}
const loadSermon = async () => {
loading.value = true
try {
const data = await $fetch(`/api/sermons/${slug}`)
sermon.value = data
} catch (error) {
console.error('Failed to load sermon:', error)
sermon.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
if (slug) {
loadSermon()
}
})
</script>

241
pages/admin.vue Normal file
View File

@@ -0,0 +1,241 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-4">
<div class="flex items-center">
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-10 w-auto mr-4" />
<UButton
@click="navigateTo('/')"
variant="ghost"
color="gray"
icon="i-heroicons-arrow-left"
class="mr-4"
>
Back to Sermons
</UButton>
<h1 class="text-2xl font-bold text-gray-900">Create New Sermon</h1>
</div>
<UButton
@click="handleLogout"
variant="outline"
color="gray"
>
Logout
</UButton>
</div>
</div>
</header>
<main class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<UCard>
<template #header>
<h2 class="text-xl font-semibold">Sermon Details</h2>
</template>
<form @submit.prevent="handleSubmit" class="space-y-8">
<!-- Basic Information -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-gray-900">Basic Information</h3>
<UFormGroup label="Sermon Title" name="title" required>
<UInput
v-model="form.title"
placeholder="Enter sermon title"
:disabled="loading"
/>
</UFormGroup>
<UFormGroup label="Sermon Date" name="date" required>
<UInput
v-model="form.date"
type="date"
:disabled="loading"
/>
</UFormGroup>
<div class="bg-blue-50 p-4 rounded-lg">
<p class="text-sm text-blue-800">
<strong>Generated URL:</strong>
<span v-if="form.title && form.date" class="font-mono">
{{ generateSlug(form.title, form.date) }}
</span>
<span v-else class="text-blue-600">Enter title and date to see URL</span>
</p>
</div>
</div>
<!-- Bible References -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900">Bible References</h3>
<UButton
@click="addBibleReference"
variant="outline"
size="sm"
icon="i-heroicons-plus"
:disabled="loading"
>
Add Reference
</UButton>
</div>
<div class="space-y-2">
<div
v-for="(reference, index) in form.bibleReferences"
:key="index"
class="flex gap-2"
>
<UInput
v-model="form.bibleReferences[index]"
:placeholder="`Bible reference ${index + 1}`"
:disabled="loading"
class="flex-1"
/>
<UButton
@click="removeBibleReference(index)"
variant="outline"
color="red"
size="sm"
icon="i-heroicons-minus"
:disabled="loading"
/>
</div>
</div>
</div>
<!-- Personal Application -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-gray-900">Personal Application</h3>
<UTextarea
v-model="form.personalApplication"
:rows="4"
placeholder="Describe how this sermon applies to daily life..."
:disabled="loading"
/>
</div>
<!-- Pastor's Challenge -->
<div class="space-y-4">
<h3 class="text-lg font-medium text-gray-900">Pastor's Challenge</h3>
<UTextarea
v-model="form.pastorChallenge"
:rows="4"
placeholder="What challenge does the pastor give to the congregation?"
:disabled="loading"
/>
</div>
<!-- Submit -->
<div class="flex justify-end space-x-4">
<UButton
type="button"
variant="outline"
:disabled="loading"
@click="resetForm"
>
Reset
</UButton>
<UButton
type="submit"
variant="solid"
color="primary"
:loading="loading"
>
Create Sermon
</UButton>
</div>
</form>
</UCard>
<!-- Success Message -->
<div v-if="successMessage" class="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<p class="text-green-800">{{ successMessage }}</p>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { generateSlug } from '~/server/utils/auth'
const form = reactive({
title: '',
date: '',
bibleReferences: [''],
personalApplication: '',
pastorChallenge: ''
})
const loading = ref(false)
const successMessage = ref('')
const generateSlug = (title: string, date: string) => {
if (!title || !date) return ''
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}`
}
const addBibleReference = () => {
form.bibleReferences.push('')
}
const removeBibleReference = (index: number) => {
if (form.bibleReferences.length > 1) {
form.bibleReferences.splice(index, 1)
}
}
const resetForm = () => {
form.title = ''
form.date = ''
form.bibleReferences = ['']
form.personalApplication = ''
form.pastorChallenge = ''
successMessage.value = ''
}
const handleSubmit = async () => {
loading.value = true
successMessage.value = ''
try {
await $fetch('/api/sermons', {
method: 'POST',
body: {
title: form.title,
date: form.date,
bibleReferences: form.bibleReferences.filter(ref => ref.trim() !== ''),
personalApplication: form.personalApplication,
pastorChallenge: form.pastorChallenge
}
})
successMessage.value = 'Sermon created successfully!'
resetForm()
} catch (error: any) {
console.error('Failed to create sermon:', error)
// Error handling is done by Nuxt automatically
} finally {
loading.value = false
}
}
const handleLogout = async () => {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/')
}
</script>

141
pages/index.vue Normal file
View File

@@ -0,0 +1,141 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-4">
<!-- Logo -->
<div class="flex items-center">
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-12 w-auto mr-3" />
</div>
<!-- Welcome Message -->
<div class="text-center flex-1">
<h1 class="text-2xl font-bold text-gray-900">
Welcome to New Life!
</h1>
<p class="text-gray-600 mt-1">
Please choose the sermon you'd like to see.
</p>
</div>
<!-- Admin Login Button -->
<div class="flex items-center space-x-4">
<UButton
v-if="!isLoggedIn"
@click="showLoginModal = true"
variant="outline"
color="primary"
>
Admin Login
</UButton>
<UButton
v-else
@click="navigateTo('/admin')"
variant="solid"
color="primary"
>
Create Sermon
</UButton>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Time Filter -->
<div class="mb-8 flex justify-center">
<USelect
v-model="timeFilter"
:options="timeOptions"
@update:model-value="loadSermons"
class="w-48"
/>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex justify-center py-12">
<UIcon name="i-heroicons-arrow-path" class="animate-spin h-8 w-8 text-primary" />
</div>
<!-- Sermons Grid -->
<div v-else-if="sermons.length > 0" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<SermonCard
v-for="sermon in sermons"
:key="sermon.id"
:sermon="sermon"
/>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<UIcon name="i-heroicons-book-open" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900">No sermons found</h3>
<p class="mt-1 text-sm text-gray-500">
{{ timeFilter === '3months' ? 'No sermons in the last 3 months.' : 'No sermons found for the selected period.' }}
</p>
</div>
</main>
<!-- Login Modal -->
<LoginModal
v-model="showLoginModal"
@success="handleLoginSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { format, parseISO, isAfter, subMonths } from 'date-fns'
const timeFilter = ref('3months')
const sermons = ref([])
const loading = ref(true)
const showLoginModal = ref(false)
const isLoggedIn = ref(false)
const timeOptions = [
{ label: 'Last 3 Months', value: '3months' },
{ label: 'Last 6 Months', value: '6months' },
{ label: 'Last Year', value: '1year' },
{ label: 'All Sermons', value: 'all' }
]
const loadSermons = async () => {
loading.value = true
try {
const { data } = await $fetch('/api/sermons', {
query: { time: timeFilter.value }
})
sermons.value = data || []
} catch (error) {
console.error('Failed to load sermons:', error)
sermons.value = []
} finally {
loading.value = false
}
}
// Check if user is logged in
onMounted(async () => {
// Check for existing auth token
const token = useCookie('auth_token')
if (token.value) {
try {
// Verify token with server
await $fetch('/api/auth/verify')
isLoggedIn.value = true
} catch {
isLoggedIn.value = false
}
}
await loadSermons()
})
const handleLoginSuccess = () => {
showLoginModal.value = false
isLoggedIn.value = true
}
</script>

BIN
pages/logos/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

33
public/fonts/README.md Normal file
View File

@@ -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

View File

@@ -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
}
}
})

View File

@@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
// Clear the auth cookie
deleteCookie(event, 'auth_token')
return { success: true }
})

View File

@@ -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
}
}
})

View File

@@ -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) : []
}
})

View File

@@ -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
})

View File

@@ -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
}
})

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

@@ -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<User | null> {
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<string> {
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}`
}

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

@@ -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
}
}

34
tailwind.config.js Normal file
View File

@@ -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: [],
}