Starting over
This commit is contained in:
53
Dockerfile
53
Dockerfile
@@ -1,53 +0,0 @@
|
|||||||
# Stage 1: Builder
|
|
||||||
FROM node:24-alpine AS builder
|
|
||||||
|
|
||||||
# Install dumb-init, Python, and build tools for native builds
|
|
||||||
RUN apk add --no-cache dumb-init python3 build-base
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install all dependencies (including dev dependencies for building)
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: Runner
|
|
||||||
FROM node:24-alpine
|
|
||||||
|
|
||||||
# Install dumb-init, wget for healthcheck, and build tools for native modules
|
|
||||||
RUN apk add --no-cache dumb-init wget python3 build-base
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files for rebuilding native modules
|
|
||||||
COPY --from=builder /app/package*.json ./
|
|
||||||
|
|
||||||
# Copy node_modules from builder
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
|
|
||||||
# Rebuild native modules for production environment
|
|
||||||
RUN npm rebuild better-sqlite3
|
|
||||||
|
|
||||||
# Copy the built application from builder stage
|
|
||||||
COPY --from=builder /app/.output ./.output
|
|
||||||
|
|
||||||
# Create data directory (don't pre-create database file)
|
|
||||||
RUN mkdir -p /app/data
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Use dumb-init to handle signals properly
|
|
||||||
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
|
||||||
101
README.md
101
README.md
@@ -1,101 +0,0 @@
|
|||||||
# 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
13
app.vue
@@ -1,13 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<NuxtPage />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// Global app setup can go here
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Global styles if needed */
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
services:
|
|
||||||
sermons:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "3002:3000"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
- JWT_SECRET=d8c7c1735fc853b807c1bccce791b054
|
|
||||||
- ADMIN_PASSWORD=admin123
|
|
||||||
volumes:
|
|
||||||
- sermon_data:/app/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
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
|
||||||
const token = useCookie('auth_token')
|
|
||||||
|
|
||||||
if (!token.value && to.path === '/admin') {
|
|
||||||
// Redirect to login page if not authenticated and trying to access admin
|
|
||||||
return navigateTo('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token.value) {
|
|
||||||
try {
|
|
||||||
// Verify token with server
|
|
||||||
await $fetch('/api/auth/verify')
|
|
||||||
} catch {
|
|
||||||
// If token is invalid, clear it and redirect to login
|
|
||||||
token.value = ''
|
|
||||||
if (to.path === '/admin') {
|
|
||||||
return navigateTo('/login')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
export default defineNuxtConfig({
|
|
||||||
compatibilityDate: '2024-04-03',
|
|
||||||
devtools: { enabled: false },
|
|
||||||
css: ['~/assets/css/main.css'],
|
|
||||||
modules: [
|
|
||||||
'@nuxt/ui'
|
|
||||||
],
|
|
||||||
colorMode: {
|
|
||||||
preference: 'light'
|
|
||||||
},
|
|
||||||
runtimeConfig: {
|
|
||||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key',
|
|
||||||
adminPassword: process.env.ADMIN_PASSWORD || 'admin123'
|
|
||||||
},
|
|
||||||
nitro: {
|
|
||||||
experimental: {
|
|
||||||
wasm: true
|
|
||||||
},
|
|
||||||
externals: {
|
|
||||||
inline: ['better-sqlite3']
|
|
||||||
},
|
|
||||||
moduleSideEffects: ['better-sqlite3'],
|
|
||||||
alias: {
|
|
||||||
'tailwindcss/colors': 'tailwindcss/colors.js'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
head: {
|
|
||||||
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@400;500;600;700&display=swap'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
32
package.json
32
package.json
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"start": "nuxt start"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"nuxt": "^4.0.0",
|
|
||||||
"@nuxt/ui": "^4.0.0",
|
|
||||||
"better-sqlite3": "^11.3.0",
|
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"jose": "^5.3.0",
|
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"bcryptjs": "^2.4.3",
|
|
||||||
"tailwindcss": "^3.0.0",
|
|
||||||
"postcss": "^8.0.0",
|
|
||||||
"autoprefixer": "^10.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.4.0",
|
|
||||||
"@types/node": "^20.0.0",
|
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
|
||||||
"@types/qrcode": "^1.5.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
154
pages/[slug].vue
154
pages/[slug].vue
@@ -1,154 +0,0 @@
|
|||||||
<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="logo-image" />
|
|
||||||
<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>
|
|
||||||
244
pages/admin.vue
244
pages/admin.vue
@@ -1,244 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-100">
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="bg-white shadow-sm border-b py-2 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-10" />
|
|
||||||
<UButton
|
|
||||||
@click="navigateTo('/')"
|
|
||||||
variant="ghost"
|
|
||||||
color="gray"
|
|
||||||
icon="i-heroicons-arrow-left"
|
|
||||||
>
|
|
||||||
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>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="py-8 max-w-xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<UCard :ui="{ base: 'mx-auto', sm: 'max-w-md' }">
|
|
||||||
<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">
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<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"
|
|
||||||
icon="i-heroicons-calendar-days-20-solid"
|
|
||||||
type="date"
|
|
||||||
:disabled="loading"
|
|
||||||
variant="outline"
|
|
||||||
/>
|
|
||||||
</UFormGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UFormGroup label="Generated URL" name="generatedUrl">
|
|
||||||
<UInput
|
|
||||||
:value="form.title && form.date ? generateSlug(form.title, form.date) : 'Enter title and date to see URL'"
|
|
||||||
:disabled="loading"
|
|
||||||
readonly
|
|
||||||
variant="outline"
|
|
||||||
/>
|
|
||||||
</UFormGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UDivider />
|
|
||||||
|
|
||||||
<!-- Bible References -->
|
|
||||||
<UFormGroup label="Bible References" name="bibleReferences">
|
|
||||||
<div
|
|
||||||
v-for="(reference, index) in form.bibleReferences"
|
|
||||||
:key="index"
|
|
||||||
class="flex items-center gap-2 mb-2"
|
|
||||||
>
|
|
||||||
<UInput
|
|
||||||
v-model="form.bibleReferences[index]"
|
|
||||||
:placeholder="`Bible reference ${index + 1}`"
|
|
||||||
:disabled="loading"
|
|
||||||
class="flex-1"
|
|
||||||
variant="outline"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
@click="removeBibleReference(index)"
|
|
||||||
variant="ghost"
|
|
||||||
color="red"
|
|
||||||
icon="i-heroicons-minus"
|
|
||||||
:disabled="loading"
|
|
||||||
v-if="form.bibleReferences.length > 1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
@click="addBibleReference"
|
|
||||||
variant="link"
|
|
||||||
color="primary"
|
|
||||||
icon="i-heroicons-plus"
|
|
||||||
:disabled="loading"
|
|
||||||
:padded="false"
|
|
||||||
class="-ml-1"
|
|
||||||
>
|
|
||||||
Add another reference
|
|
||||||
</UButton>
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UDivider />
|
|
||||||
|
|
||||||
<!-- Personal Application -->
|
|
||||||
<UFormGroup label="Personal Application" name="personalApplication">
|
|
||||||
<UTextarea
|
|
||||||
v-model="form.personalApplication"
|
|
||||||
:rows="4"
|
|
||||||
placeholder="Describe how this sermon applies to daily life..."
|
|
||||||
:disabled="loading"
|
|
||||||
variant="outline"
|
|
||||||
/>
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UDivider />
|
|
||||||
|
|
||||||
<!-- Pastor's Challenge -->
|
|
||||||
<UFormGroup label="Pastor's Challenge" name="pastorChallenge">
|
|
||||||
<UTextarea
|
|
||||||
v-model="form.pastorChallenge"
|
|
||||||
:rows="4"
|
|
||||||
placeholder="What challenge does the pastor give to the congregation?"
|
|
||||||
:disabled="loading"
|
|
||||||
variant="outline"
|
|
||||||
/>
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
definePageMeta({
|
|
||||||
middleware: ['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>
|
|
||||||
130
pages/index.vue
130
pages/index.vue
@@ -1,130 +0,0 @@
|
|||||||
<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="logo-image" />
|
|
||||||
</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="navigateTo('/login')"
|
|
||||||
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>
|
|
||||||
|
|
||||||
</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 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()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-100">
|
|
||||||
<UCard class="w-full max-w-md">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<img src="/logos/logo.png" alt="New Life Christian Church" class="h-16 mb-4" />
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Admin Login</h2>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
|
||||||
<UFormGroup label="Username" name="username" required>
|
|
||||||
<UInput
|
|
||||||
v-model="form.username"
|
|
||||||
placeholder="Enter username"
|
|
||||||
icon="i-heroicons-user"
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UFormGroup label="Password" name="password" required>
|
|
||||||
<UInput
|
|
||||||
v-model="form.password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter password"
|
|
||||||
icon="i-heroicons-lock-closed"
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<div v-if="error" class="text-red-600 text-sm p-3 bg-red-50 border border-red-200 rounded-md">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
type="submit"
|
|
||||||
variant="solid"
|
|
||||||
color="primary"
|
|
||||||
size="lg"
|
|
||||||
block
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</UButton>
|
|
||||||
</form>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Redirect to admin page on successful login
|
|
||||||
await navigateTo('/admin')
|
|
||||||
} catch (err: any) {
|
|
||||||
error.value = err.data?.statusMessage || 'Login failed'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 343 KiB |
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# 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
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 343 KiB |
@@ -1,37 +0,0 @@
|
|||||||
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: false,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 7 * 24 * 60 * 60 // 7 days
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
|
||||||
// Clear the auth cookie
|
|
||||||
deleteCookie(event, 'auth_token')
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
})
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { getDatabase } from '~/server/utils/database.server'
|
|
||||||
import { getRouterParam, createError } from 'h3'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
try {
|
|
||||||
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) : []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading sermon:', error)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'Failed to load sermon'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { getDatabase } from '~/server/utils/database.server'
|
|
||||||
import { verifyJWT } from '~/server/utils/auth'
|
|
||||||
import { getQuery } from 'h3'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
try {
|
|
||||||
const db = await getDatabase()
|
|
||||||
|
|
||||||
// Check for time filter
|
|
||||||
const query = getQuery(event)
|
|
||||||
const timeFilter = query.time as string || '3months'
|
|
||||||
|
|
||||||
let dateFilter = '1970-01-01T00:00:00.000Z'
|
|
||||||
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-01T00:00:00.000Z'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const sermons = db.prepare(`
|
|
||||||
SELECT * FROM sermons
|
|
||||||
WHERE date >= ?
|
|
||||||
ORDER BY date DESC
|
|
||||||
`).all(dateFilter) as any[]
|
|
||||||
|
|
||||||
return { data: sermons || [] }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading sermons:', error)
|
|
||||||
return { data: [] }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { getDatabase } from '~/server/utils/database.server'
|
|
||||||
import { verifyJWT } from '~/server/utils/auth'
|
|
||||||
|
|
||||||
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}`
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import bcrypt from 'bcryptjs'
|
|
||||||
import { SignJWT, jwtVerify } from 'jose'
|
|
||||||
import { getDatabase } from './database.server'
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import Database from 'better-sqlite3'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { existsSync, mkdirSync } from 'fs'
|
|
||||||
import { dirname } from 'path'
|
|
||||||
import bcrypt from 'bcryptjs'
|
|
||||||
|
|
||||||
let db: Database.Database
|
|
||||||
|
|
||||||
export async function getDatabase() {
|
|
||||||
if (!db) {
|
|
||||||
// Use absolute path in production (Docker), relative path in development
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production'
|
|
||||||
const dbPath = isProduction
|
|
||||||
? '/app/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
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Create default admin user if it doesn't exist
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const saltRounds = 10
|
|
||||||
const passwordHash = await bcrypt.hash(config.adminPassword, saltRounds)
|
|
||||||
|
|
||||||
const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
|
||||||
if (!existingAdmin) {
|
|
||||||
db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run('admin', passwordHash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeDatabase() {
|
|
||||||
if (db) {
|
|
||||||
db.close()
|
|
||||||
db = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
/** @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: [],
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user