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

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>