Initial commit - source code only with SecondThirdSaturday recurring type fix

This commit is contained in:
RTSDA 2025-08-16 18:28:35 -04:00
commit 13993ecd25
86 changed files with 24103 additions and 0 deletions

46
.gitignore vendored Normal file
View file

@ -0,0 +1,46 @@
# Rust build artifacts
target/
Cargo.lock
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Astro build output
dist/
.astro/
# Compiled binaries and libraries
*.node
*.so
*.dll
*.dylib
*.a
# Archive files
*.tar.gz
*.zip
# iOS/Android build artifacts
bindings/
*.xcframework/
RTSDA/
# Build outputs
build/
*.log
# IDE
.vscode/
.idea/
# OS generated files
.DS_Store
Thumbs.db
ehthumbs.db
# Temporary files
*.tmp
*.temp

View file

@ -0,0 +1,16 @@
[package]
name = "church-core-bindings"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
napi = { version = "2", default-features = false, features = ["napi4"] }
napi-derive = "2"
church-core = { path = "../../church-core", features = ["uniffi"] }
tokio = { version = "1", features = ["full"] }
[build-dependencies]
napi-build = "2"

View file

@ -0,0 +1,13 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone'
}),
integrations: [tailwind()]
});

View file

@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}

View file

@ -0,0 +1,336 @@
/* tslint:disable */
/* eslint-disable */
/* prettier-ignore */
/* auto-generated by NAPI-RS */
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { platform, arch } = process
let nativeBinding = null
let localFileExisted = false
let loadError = null
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
const lddPath = require('child_process').execSync('which ldd').toString().trim()
return readFileSync(lddPath, 'utf8').includes('musl')
} catch (e) {
return true
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
}
switch (platform) {
case 'android':
switch (arch) {
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.android-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.android-arm64.node')
} else {
nativeBinding = require('astro-church-website-android-arm64')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.android-arm-eabi.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.android-arm-eabi.node')
} else {
nativeBinding = require('astro-church-website-android-arm-eabi')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Android ${arch}`)
}
break
case 'win32':
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.win32-x64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.win32-x64-msvc.node')
} else {
nativeBinding = require('astro-church-website-win32-x64-msvc')
}
} catch (e) {
loadError = e
}
break
case 'ia32':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.win32-ia32-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.win32-ia32-msvc.node')
} else {
nativeBinding = require('astro-church-website-win32-ia32-msvc')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.win32-arm64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.win32-arm64-msvc.node')
} else {
nativeBinding = require('astro-church-website-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`)
}
break
case 'darwin':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.darwin-universal.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.darwin-universal.node')
} else {
nativeBinding = require('astro-church-website-darwin-universal')
}
break
} catch {}
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.darwin-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.darwin-x64.node')
} else {
nativeBinding = require('astro-church-website-darwin-x64')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.darwin-arm64.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.darwin-arm64.node')
} else {
nativeBinding = require('astro-church-website-darwin-arm64')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`)
}
break
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
}
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.freebsd-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.freebsd-x64.node')
} else {
nativeBinding = require('astro-church-website-freebsd-x64')
}
} catch (e) {
loadError = e
}
break
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-x64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-x64-musl.node')
} else {
nativeBinding = require('astro-church-website-linux-x64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-x64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-x64-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-x64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm64-musl.node')
} else {
nativeBinding = require('astro-church-website-linux-arm64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm64-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-arm64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm-musleabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm-musleabihf.node')
} else {
nativeBinding = require('astro-church-website-linux-arm-musleabihf')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm-gnueabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm-gnueabihf.node')
} else {
nativeBinding = require('astro-church-website-linux-arm-gnueabihf')
}
} catch (e) {
loadError = e
}
}
break
case 'riscv64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-riscv64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-riscv64-musl.node')
} else {
nativeBinding = require('astro-church-website-linux-riscv64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-riscv64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-riscv64-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-riscv64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 's390x':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-s390x-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-s390x-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-s390x-gnu')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
}
break
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
}
if (!nativeBinding) {
if (loadError) {
throw loadError
}
throw new Error(`Failed to load native binding`)
}
const { getChurchName, fetchEventsJson, fetchFeaturedEventsJson, fetchSermonsJson, fetchConfigJson, getMissionStatement, fetchRandomBibleVerseJson, getStreamLiveStatus, getLivestreamUrl, getChurchAddress, getContactPhone, getContactEmail, getFacebookUrl, getYoutubeUrl, getInstagramUrl, submitContactV2Json, validateContactFormJson, fetchLivestreamArchiveJson, fetchBulletinsJson, fetchCurrentBulletinJson, fetchBibleVerseJson, submitEventJson } = nativeBinding
module.exports.getChurchName = getChurchName
module.exports.fetchEventsJson = fetchEventsJson
module.exports.fetchFeaturedEventsJson = fetchFeaturedEventsJson
module.exports.fetchSermonsJson = fetchSermonsJson
module.exports.fetchConfigJson = fetchConfigJson
module.exports.getMissionStatement = getMissionStatement
module.exports.fetchRandomBibleVerseJson = fetchRandomBibleVerseJson
module.exports.getStreamLiveStatus = getStreamLiveStatus
module.exports.getLivestreamUrl = getLivestreamUrl
module.exports.getChurchAddress = getChurchAddress
module.exports.getContactPhone = getContactPhone
module.exports.getContactEmail = getContactEmail
module.exports.getFacebookUrl = getFacebookUrl
module.exports.getYoutubeUrl = getYoutubeUrl
module.exports.getInstagramUrl = getInstagramUrl
module.exports.submitContactV2Json = submitContactV2Json
module.exports.validateContactFormJson = validateContactFormJson
module.exports.fetchLivestreamArchiveJson = fetchLivestreamArchiveJson
module.exports.fetchBulletinsJson = fetchBulletinsJson
module.exports.fetchCurrentBulletinJson = fetchCurrentBulletinJson
module.exports.fetchBibleVerseJson = fetchBibleVerseJson
module.exports.submitEventJson = submitEventJson

27
astro-church-website/index.d.ts vendored Normal file
View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/* auto-generated by NAPI-RS */
export declare function getChurchName(): string
export declare function fetchEventsJson(): string
export declare function fetchFeaturedEventsJson(): string
export declare function fetchSermonsJson(): string
export declare function fetchConfigJson(): string
export declare function getMissionStatement(): string
export declare function fetchRandomBibleVerseJson(): string
export declare function getStreamLiveStatus(): boolean
export declare function getLivestreamUrl(): string
export declare function getChurchAddress(): string
export declare function getContactPhone(): string
export declare function getContactEmail(): string
export declare function getFacebookUrl(): string
export declare function getYoutubeUrl(): string
export declare function getInstagramUrl(): string
export declare function submitContactV2Json(name: string, email: string, subject: string, message: string, phone: string): string
export declare function validateContactFormJson(formJson: string): string
export declare function fetchLivestreamArchiveJson(): string
export declare function fetchBulletinsJson(): string
export declare function fetchCurrentBulletinJson(): string
export declare function fetchBibleVerseJson(query: string): string
export declare function submitEventJson(title: string, description: string, startTime: string, endTime: string, location: string, locationUrl: string | undefined | null, category: string, recurringType?: string | undefined | null, submitterEmail?: string | undefined | null): string

View file

@ -0,0 +1,336 @@
/* tslint:disable */
/* eslint-disable */
/* prettier-ignore */
/* auto-generated by NAPI-RS */
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { platform, arch } = process
let nativeBinding = null
let localFileExisted = false
let loadError = null
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
const lddPath = require('child_process').execSync('which ldd').toString().trim()
return readFileSync(lddPath, 'utf8').includes('musl')
} catch (e) {
return true
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
}
switch (platform) {
case 'android':
switch (arch) {
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.android-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.android-arm64.node')
} else {
nativeBinding = require('astro-church-website-android-arm64')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.android-arm-eabi.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.android-arm-eabi.node')
} else {
nativeBinding = require('astro-church-website-android-arm-eabi')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Android ${arch}`)
}
break
case 'win32':
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.win32-x64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.win32-x64-msvc.node')
} else {
nativeBinding = require('astro-church-website-win32-x64-msvc')
}
} catch (e) {
loadError = e
}
break
case 'ia32':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.win32-ia32-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.win32-ia32-msvc.node')
} else {
nativeBinding = require('astro-church-website-win32-ia32-msvc')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.win32-arm64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.win32-arm64-msvc.node')
} else {
nativeBinding = require('astro-church-website-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`)
}
break
case 'darwin':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.darwin-universal.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.darwin-universal.node')
} else {
nativeBinding = require('astro-church-website-darwin-universal')
}
break
} catch {}
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.darwin-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.darwin-x64.node')
} else {
nativeBinding = require('astro-church-website-darwin-x64')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.darwin-arm64.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.darwin-arm64.node')
} else {
nativeBinding = require('astro-church-website-darwin-arm64')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`)
}
break
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
}
localFileExisted = existsSync(join(__dirname, 'church-core-bindings.freebsd-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.freebsd-x64.node')
} else {
nativeBinding = require('astro-church-website-freebsd-x64')
}
} catch (e) {
loadError = e
}
break
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-x64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-x64-musl.node')
} else {
nativeBinding = require('astro-church-website-linux-x64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-x64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-x64-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-x64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm64-musl.node')
} else {
nativeBinding = require('astro-church-website-linux-arm64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm64-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-arm64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm-musleabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm-musleabihf.node')
} else {
nativeBinding = require('astro-church-website-linux-arm-musleabihf')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-arm-gnueabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-arm-gnueabihf.node')
} else {
nativeBinding = require('astro-church-website-linux-arm-gnueabihf')
}
} catch (e) {
loadError = e
}
}
break
case 'riscv64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-riscv64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-riscv64-musl.node')
} else {
nativeBinding = require('astro-church-website-linux-riscv64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-riscv64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-riscv64-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-riscv64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 's390x':
localFileExisted = existsSync(
join(__dirname, 'church-core-bindings.linux-s390x-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./church-core-bindings.linux-s390x-gnu.node')
} else {
nativeBinding = require('astro-church-website-linux-s390x-gnu')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
}
break
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
}
if (!nativeBinding) {
if (loadError) {
throw loadError
}
throw new Error(`Failed to load native binding`)
}
const { getChurchName, fetchEventsJson, fetchFeaturedEventsJson, fetchSermonsJson, fetchConfigJson, getMissionStatement, fetchRandomBibleVerseJson, getStreamLiveStatus, getLivestreamUrl, getChurchAddress, getContactPhone, getContactEmail, getFacebookUrl, getYoutubeUrl, getInstagramUrl, submitContactV2Json, validateContactFormJson, fetchLivestreamArchiveJson, fetchBulletinsJson, fetchCurrentBulletinJson, fetchBibleVerseJson, submitEventJson } = nativeBinding
module.exports.getChurchName = getChurchName
module.exports.fetchEventsJson = fetchEventsJson
module.exports.fetchFeaturedEventsJson = fetchFeaturedEventsJson
module.exports.fetchSermonsJson = fetchSermonsJson
module.exports.fetchConfigJson = fetchConfigJson
module.exports.getMissionStatement = getMissionStatement
module.exports.fetchRandomBibleVerseJson = fetchRandomBibleVerseJson
module.exports.getStreamLiveStatus = getStreamLiveStatus
module.exports.getLivestreamUrl = getLivestreamUrl
module.exports.getChurchAddress = getChurchAddress
module.exports.getContactPhone = getContactPhone
module.exports.getContactEmail = getContactEmail
module.exports.getFacebookUrl = getFacebookUrl
module.exports.getYoutubeUrl = getYoutubeUrl
module.exports.getInstagramUrl = getInstagramUrl
module.exports.submitContactV2Json = submitContactV2Json
module.exports.validateContactFormJson = validateContactFormJson
module.exports.fetchLivestreamArchiveJson = fetchLivestreamArchiveJson
module.exports.fetchBulletinsJson = fetchBulletinsJson
module.exports.fetchCurrentBulletinJson = fetchCurrentBulletinJson
module.exports.fetchBibleVerseJson = fetchBibleVerseJson
module.exports.submitEventJson = submitEventJson

6444
astro-church-website/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
{
"name": "astro-church-website",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "npm run build:native && astro build",
"build:native": "napi build --platform --release",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.4.2",
"@astrojs/tailwind": "^6.0.2",
"astro": "^5.13.0",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.4"
},
"napi": {
"name": "church-core-bindings",
"moduleType": "cjs",
"triples": {
"defaults": true,
"additional": [
"x86_64-pc-windows-msvc",
"i686-pc-windows-msvc",
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"armv7-unknown-linux-gnueabihf"
]
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect x="54" y="16" width="20" height="96" rx="2" />
<rect x="32" y="38" width="64" height="20" rx="2" />
<style>
rect { fill: #4F46E5; }
@media (prefers-color-scheme: dark) {
rect { fill: #818CF8; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 332 B

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download on the App Store</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269C110.85987,0,110.49457,0,110.13477,0Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875h102.769l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993A5.76267,5.76267,0,0,1,118.8672,5.667a12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.5459,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238h1.79883v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238h1.79883v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C63.60984,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238h1.31641V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g>
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.13381,2.13381,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,209 @@
// Shared live status polling functionality
class LiveStatusUpdater {
constructor() {
this.currentlyLive = false;
this.currentStreamUrl = '';
this.pollInterval = null;
this.apiUrl = 'https://api.rockvilletollandsda.church/api/v1/stream/status';
this.hls = null;
}
// Initialize for home page live banner
initHomePage() {
const liveBanner = document.getElementById('live-status-banner');
if (!liveBanner) return;
this.currentlyLive = liveBanner.style.display !== 'none';
this.startPolling(() => this.updateHomeBanner(liveBanner));
}
// Initialize for live page video player
initLivePage() {
const videoContainer = document.querySelector('.aspect-video');
if (!videoContainer) return;
const hasVideo = videoContainer.querySelector('video') !== null;
this.currentlyLive = hasVideo;
this.currentStreamUrl = hasVideo ? videoContainer.querySelector('video')?.src || '' : '';
this.startPolling(() => this.updateLivePage(videoContainer));
}
startPolling(updateCallback) {
// Check immediately on load
this.checkStatus(updateCallback);
// Then check every 30 seconds
this.pollInterval = setInterval(() => {
this.checkStatus(updateCallback);
}, 30000);
}
async checkStatus(updateCallback) {
try {
console.log('Checking live status...');
const response = await fetch(this.apiUrl);
const data = await response.json();
console.log('API response:', data);
console.log('Current state:', { currentlyLive: this.currentlyLive, currentStreamUrl: this.currentStreamUrl });
// Only update if status changed
if (data.is_live !== this.currentlyLive || data.stream_url !== this.currentStreamUrl) {
console.log('Status changed! Updating UI...');
this.currentlyLive = data.is_live;
this.currentStreamUrl = data.stream_url || '';
updateCallback(data);
} else {
console.log('No status change detected');
}
} catch (error) {
console.error('Failed to update live status:', error);
}
}
updateHomeBanner(liveBanner) {
if (this.currentlyLive) {
liveBanner.style.display = 'block';
} else {
liveBanner.style.display = 'none';
}
}
// Helper method to detect if browser supports native HLS
supportsNativeHLS() {
const video = document.createElement('video');
return video.canPlayType('application/vnd.apple.mpegurl') !== '';
}
// Helper method to clean up existing HLS instance
destroyHLS() {
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
}
// Helper method to setup HLS.js for non-Safari browsers
setupHLS(video, streamUrl) {
// Clean up any existing HLS instance
this.destroyHLS();
if (this.supportsNativeHLS()) {
// Safari - use native HLS support
video.src = streamUrl;
} else if (window.Hls && window.Hls.isSupported()) {
// Chrome, Firefox - use HLS.js
this.hls = new window.Hls({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90
});
this.hls.loadSource(streamUrl);
this.hls.attachMedia(video);
this.hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
console.log('HLS manifest parsed, starting playback');
video.play().catch(e => console.log('Auto-play prevented:', e));
});
this.hls.on(window.Hls.Events.ERROR, (event, data) => {
console.error('HLS error:', event, data);
if (data.fatal) {
switch (data.type) {
case window.Hls.ErrorTypes.NETWORK_ERROR:
console.log('Network error, trying to recover...');
this.hls.startLoad();
break;
case window.Hls.ErrorTypes.MEDIA_ERROR:
console.log('Media error, trying to recover...');
this.hls.recoverMediaError();
break;
default:
console.log('Fatal error, destroying HLS instance');
this.destroyHLS();
break;
}
}
});
} else {
// Fallback for browsers that don't support HLS at all
console.warn('HLS not supported in this browser');
video.src = streamUrl;
}
}
updateLivePage(videoContainer) {
// Update hero section status indicator
const heroStatusContainer = document.getElementById('live-status-hero');
console.log('Hero status container found:', heroStatusContainer);
if (heroStatusContainer) {
if (this.currentlyLive) {
heroStatusContainer.innerHTML = `
<div class="flex items-center space-x-2 bg-red-500 rounded-full px-4 py-2">
<div class="w-3 h-3 bg-white rounded-full animate-ping"></div>
<span class="font-semibold">LIVE NOW</span>
</div>
`;
} else {
heroStatusContainer.innerHTML = `
<div class="flex items-center space-x-2 bg-white/20 rounded-full px-4 py-2">
<div class="w-3 h-3 bg-gray-300 rounded-full"></div>
<span class="font-semibold">OFFLINE</span>
</div>
`;
}
}
// Update video player
if (this.currentlyLive && this.currentStreamUrl) {
// Create/update video element
videoContainer.innerHTML = `
<video
id="live-video-player"
class="w-full h-full"
controls
muted
playsinline
preload="none">
<p class="text-white text-center">Your browser does not support the video tag or HLS streaming.</p>
</video>
`;
// Setup HLS for the new video element
const video = document.getElementById('live-video-player');
if (video) {
this.setupHLS(video, this.currentStreamUrl);
}
} else {
// Clean up HLS when going offline
this.destroyHLS();
// Show offline message
videoContainer.innerHTML = `
<div class="w-full h-full flex items-center justify-center bg-gray-900 text-white">
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-4 text-gray-400">
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="M21 6.5l-4 4V7c0-0.55-0.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-0.55 0-1 0.45-1 1v10c0 0.55 0.45 1 1 1h12c0.21 0 0.39-0.08 0.54-0.18L19.73 21 21 19.73 3.27 2z"/>
</svg>
</div>
<h3 class="text-2xl font-semibold mb-2">Stream is Offline</h3>
<p class="text-gray-400">We'll be back for our next service</p>
</div>
</div>
`;
}
}
destroy() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
this.destroyHLS();
}
}
// Global instance
window.liveStatusUpdater = new LiveStatusUpdater();

View file

@ -0,0 +1,286 @@
---
import { SERVICE_TIMES } from '../lib/constants.js';
import {
getChurchName,
getChurchAddress,
getContactPhone,
getContactEmail,
getFacebookUrl,
getYoutubeUrl,
getInstagramUrl,
getMissionStatement
} from '../lib/bindings.js';
let churchName = 'Church';
let address = '';
let phone = '';
let email = '';
let facebookUrl = '';
let youtubeUrl = '';
let instagramUrl = '';
let missionStatement = '';
try {
churchName = getChurchName();
address = getChurchAddress();
phone = getContactPhone();
// Get the base email from church-core and make it dynamic based on current domain
const baseEmail = getContactEmail();
const currentUrl = Astro.url.hostname;
// Extract the domain part and create dynamic email
if (currentUrl && currentUrl !== 'localhost') {
email = `admin@${currentUrl}`;
} else {
// Fallback to the original email from church-core
email = baseEmail;
}
facebookUrl = getFacebookUrl();
youtubeUrl = getYoutubeUrl();
instagramUrl = getInstagramUrl();
missionStatement = getMissionStatement();
} catch (e) {
console.error('Failed to get church config:', e);
}
const currentYear = new Date().getFullYear();
---
<footer class="bg-gradient-to-r from-earth-900 via-earth-800 to-earth-900 text-white relative overflow-hidden">
<!-- Background Pattern -->
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%236366f1" fill-opacity="0.05"%3E%3Cpath d="M20 20c0 11.046-8.954 20-20 20s-20-8.954-20-20 8.954-20 20-20 20 8.954 20 20z"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-30"></div>
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<!-- Three Angels' Message Section -->
<div class="text-center mb-16">
<h2 class="text-4xl lg:text-5xl font-bold text-golden-gradient mb-6">The Three Angels' Message</h2>
<div class="grid md:grid-cols-3 gap-8 max-w-4xl mx-auto">
<!-- First Angel -->
<div class="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10 hover:bg-white/10 transition-all duration-300 group" data-animate>
<div class="w-16 h-16 bg-gradient-to-br from-primary-500 to-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<span class="text-2xl font-bold text-white">1</span>
</div>
<h3 class="text-xl font-semibold text-primary-300 mb-3">Fear God</h3>
<p class="text-gray-300 text-sm leading-relaxed">
"Fear God and give glory to Him, for the hour of His judgment has come"
</p>
</div>
<!-- Second Angel -->
<div class="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10 hover:bg-white/10 transition-all duration-300 group" data-animate>
<div class="w-16 h-16 bg-gradient-to-br from-primary-500 to-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<span class="text-2xl font-bold text-white">2</span>
</div>
<h3 class="text-xl font-semibold text-primary-300 mb-3">Babylon Fallen</h3>
<p class="text-gray-300 text-sm leading-relaxed">
"Babylon is fallen, is fallen, that great city"
</p>
</div>
<!-- Third Angel -->
<div class="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10 hover:bg-white/10 transition-all duration-300 group" data-animate>
<div class="w-16 h-16 bg-gradient-to-br from-primary-500 to-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<span class="text-2xl font-bold text-white">3</span>
</div>
<h3 class="text-xl font-semibold text-primary-300 mb-3">Mark of Beast</h3>
<p class="text-gray-300 text-sm leading-relaxed">
"If anyone worships the beast and his image..."
</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
<!-- Church Info -->
<div class="lg:col-span-2">
<div class="flex items-center space-x-3 mb-6">
<div class="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg">
<i data-lucide="sparkles" class="w-7 h-7 text-white"></i>
</div>
<div>
<h3 class="text-2xl font-bold text-golden-gradient">{churchName}</h3>
<p class="text-gray-300 text-sm">Seventh-day Adventist Church</p>
</div>
</div>
{missionStatement ? (
<p class="text-gray-300 mb-6 leading-relaxed">
{missionStatement}
</p>
) : (
<p class="text-gray-300 mb-6 leading-relaxed">
Join us as we study God's Word and prepare for His second coming. We are committed to sharing the everlasting gospel and the Three Angels' Message of Revelation 14.
</p>
)}
<!-- Scripture Verse -->
<div class="bg-white/5 backdrop-blur-sm rounded-xl p-4 border border-white/10">
<p class="text-sm text-gray-200 italic leading-relaxed">
"Then I saw another angel flying in midheaven, having the everlasting gospel to preach to those who dwell on the earth..."
</p>
<p class="text-xs text-primary-300 mt-2 font-medium">— Revelation 14:6</p>
</div>
</div>
<!-- Quick Links -->
<div>
<h4 class="text-lg font-semibold mb-6 text-white">Quick Links</h4>
<ul class="space-y-3">
<li><a href="/about" class="footer-link"><i data-lucide="info" class="w-4 h-4"></i><span>About Us</span></a></li>
<li><a href="/sermons" class="footer-link"><i data-lucide="book-open" class="w-4 h-4"></i><span>Sermons</span></a></li>
<li><a href="/events" class="footer-link"><i data-lucide="calendar" class="w-4 h-4"></i><span>Events</span></a></li>
<li><a href="/three-angels" class="footer-link"><i data-lucide="users" class="w-4 h-4"></i><span>Three Angels</span></a></li>
<li><a href="/contact" class="footer-link"><i data-lucide="mail" class="w-4 h-4"></i><span>Contact</span></a></li>
<li><a href="/live" class="footer-link text-gold-400"><i data-lucide="video" class="w-4 h-4"></i><span>Watch Live</span></a></li>
</ul>
</div>
<!-- Contact & Connect -->
<div>
<h4 class="text-lg font-semibold mb-6 text-white">Connect With Us</h4>
<!-- Contact Info -->
<div class="space-y-3 mb-6">
{address && (
<div class="flex items-start space-x-2 text-sm text-gray-300">
<i data-lucide="map-pin" class="w-4 h-4 text-gold-400 mt-0.5 flex-shrink-0"></i>
<span>{address}</span>
</div>
)}
{phone && (
<div class="flex items-center space-x-2 text-sm text-gray-300">
<i data-lucide="phone" class="w-4 h-4 text-gold-400"></i>
<a href={`tel:${phone}`} class="hover:text-white transition-colors">{phone}</a>
</div>
)}
{email && (
<div class="flex items-center space-x-2 text-sm text-gray-300">
<i data-lucide="mail" class="w-4 h-4 text-gold-400"></i>
<a href={`mailto:${email}`} class="hover:text-white transition-colors">{email}</a>
</div>
)}
</div>
<!-- Sabbath Service Times -->
<div class="mb-6">
<h5 class="text-sm font-medium text-primary-300 mb-2">Sabbath Services</h5>
<div class="space-y-1 text-sm text-gray-300">
<div class="flex items-center space-x-2">
<i data-lucide="sun" class="w-4 h-4 text-gold-400"></i>
<span>Sabbath School: {SERVICE_TIMES.SABBATH_SCHOOL}</span>
</div>
<div class="flex items-center space-x-2">
<i data-lucide="church" class="w-4 h-4 text-gold-400"></i>
<span>Divine Service: {SERVICE_TIMES.DIVINE_SERVICE}</span>
</div>
</div>
</div>
<!-- Social Links -->
{(facebookUrl || youtubeUrl || instagramUrl) && (
<div class="mb-6">
<h5 class="text-sm font-medium text-primary-300 mb-3">Follow Us</h5>
<div class="flex space-x-3">
{facebookUrl && (
<a href={facebookUrl} target="_blank" rel="noopener" class="social-link">
<i data-lucide="facebook" class="w-5 h-5"></i>
</a>
)}
{youtubeUrl && (
<a href={youtubeUrl} target="_blank" rel="noopener" class="social-link">
<i data-lucide="youtube" class="w-5 h-5"></i>
</a>
)}
{instagramUrl && (
<a href={instagramUrl} target="_blank" rel="noopener" class="social-link">
<i data-lucide="instagram" class="w-5 h-5"></i>
</a>
)}
</div>
</div>
)}
<!-- Mobile App Downloads -->
<div>
<h5 class="text-sm font-medium text-primary-300 mb-3">Get Our App</h5>
<div class="space-y-3">
<!-- iOS App Store -->
<a href="https://apps.apple.com/us/app/rtsda/id6738595657" target="_blank" rel="noopener" class="app-download-btn">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gradient-to-br from-gray-700 to-black rounded-lg flex items-center justify-center">
<i data-lucide="smartphone" class="w-4 h-4 text-white"></i>
</div>
<div class="text-left">
<div class="text-xs text-gray-400">Download on the</div>
<div class="text-sm font-medium text-white">App Store</div>
</div>
</div>
</a>
<!-- Android APK Download -->
<button onclick="downloadApk()" class="app-download-btn w-full text-left">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gradient-to-br from-primary-600 to-primary-700 rounded-lg flex items-center justify-center">
<svg viewBox="0 0 24 24" class="w-4 h-4 fill-gold-400">
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85a.637.637 0 0 0-.83.22l-1.88 3.24a11.463 11.463 0 0 0-8.94 0L5.65 5.67a.643.643 0 0 0-.87-.2c-.28.18-.37.54-.22.83L6.4 9.48A10.78 10.78 0 0 0 1 18h22a10.78 10.78 0 0 0-5.4-8.52zM7 15.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5zm10 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5z"/>
</svg>
</div>
<div class="text-left">
<div class="text-xs text-gray-400">Download APK</div>
<div class="text-sm font-medium text-white">Android</div>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="border-t border-white/10 mt-12 pt-8">
<div class="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<div class="text-sm text-gray-400">
<p>&copy; {currentYear} {churchName}. All rights reserved.</p>
</div>
<div class="flex items-center space-x-6 text-sm text-gray-400">
<a href="/privacy" class="hover:text-white transition-colors">Privacy Policy</a>
<a href="/terms" class="hover:text-white transition-colors">Terms of Service</a>
<div class="flex items-center space-x-2">
<span>Built with love for God's glory</span>
</div>
</div>
</div>
</div>
</div>
</footer>
<style>
.footer-link {
@apply flex items-center space-x-2 text-gray-300 hover:text-white transition-colors duration-200;
}
.footer-link:hover i {
@apply text-primary-400 transform scale-110;
}
.social-link {
@apply w-10 h-10 bg-white/10 hover:bg-primary-500 rounded-lg flex items-center justify-center text-gray-300 hover:text-white transition-all duration-300 hover:scale-110 hover:shadow-lg;
}
.app-download-btn {
@apply block w-full p-3 bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 hover:border-white/20 transition-all duration-300 hover:transform hover:scale-105 hover:shadow-lg;
}
</style>
<script is:inline>
// Make downloadApk function globally available
window.downloadApk = function() {
window.location.href = 'https://api.rockvilletollandsda.church/uploads/rtsda_android/current';
};
</script>

View file

@ -0,0 +1,193 @@
---
import { getChurchName } from '../lib/bindings.js';
let churchName = 'Church';
try {
churchName = getChurchName();
} catch (e) {
console.error('Failed to get church name:', e);
}
---
<nav class="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200/20 dark:border-gray-700/20" role="navigation" aria-label="main navigation">
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16 lg:h-20">
<!-- Logo/Brand -->
<div class="flex items-center">
<a href="/" class="flex items-center space-x-3 group">
<!-- Angel Wings Icon -->
<div class="relative">
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<i data-lucide="sparkles" class="w-6 h-6 text-white"></i>
</div>
<div class="absolute -top-1 -right-1 w-4 h-4 bg-gold-500 rounded-full opacity-80 animate-ping"></div>
</div>
<div class="hidden sm:block">
<h1 class="text-xl lg:text-2xl font-bold text-divine-gradient">{churchName}</h1>
<p class="text-xs text-gray-600 dark:text-gray-400 -mt-1">Three Angels' Message</p>
</div>
</a>
</div>
<!-- Desktop Navigation -->
<div class="hidden lg:flex items-center space-x-0">
<a href="/" class="nav-link">
<i data-lucide="home" class="w-4 h-4"></i>
<span>Home</span>
</a>
<!-- Events Link -->
<a href="/events" class="nav-link">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>Events</span>
</a>
<!-- Sermons Link -->
<a href="/sermons" class="nav-link">
<i data-lucide="book-open" class="w-4 h-4"></i>
<span>Sermons</span>
</a>
<!-- Bulletin Link with Dropdown -->
<div class="relative group">
<a href="/bulletin" class="nav-link">
<i data-lucide="newspaper" class="w-4 h-4"></i>
<span>Bulletin</span>
<i data-lucide="chevron-down" class="w-4 h-4 ml-1 group-hover:rotate-180 transition-transform"></i>
</a>
<div class="dropdown-menu">
<a href="/bulletin" class="dropdown-item">
<i data-lucide="calendar-check" class="w-4 h-4 text-primary-600"></i>
<span>Current Bulletin</span>
</a>
<a href="/bulletin/archive" class="dropdown-item">
<i data-lucide="archive" class="w-4 h-4 text-primary-600"></i>
<span>Bulletin Archive</span>
</a>
</div>
</div>
<!-- Three Angels' Message -->
<div class="relative group">
<a href="/three-angels" class="nav-link">
<i data-lucide="users" class="w-4 h-4"></i>
<span>Three Angels</span>
<i data-lucide="chevron-down" class="w-4 h-4 ml-1 group-hover:rotate-180 transition-transform"></i>
</a>
<div class="dropdown-menu">
<a href="/three-angels" class="dropdown-item">
<i data-lucide="layers" class="w-4 h-4 text-primary-600"></i>
<span>Overview</span>
</a>
<a href="/three-angels/first" class="dropdown-item">
<span class="w-4 h-4 flex items-center justify-center text-xs font-bold bg-primary-100 text-primary-700 rounded">1</span>
<span>First Angel</span>
</a>
<a href="/three-angels/second" class="dropdown-item">
<span class="w-4 h-4 flex items-center justify-center text-xs font-bold bg-primary-100 text-primary-700 rounded">2</span>
<span>Second Angel</span>
</a>
<a href="/three-angels/third" class="dropdown-item">
<span class="w-4 h-4 flex items-center justify-center text-xs font-bold bg-primary-100 text-primary-700 rounded">3</span>
<span>Third Angel</span>
</a>
</div>
</div>
<a href="/about" class="nav-link">
<i data-lucide="info" class="w-4 h-4"></i>
<span>About</span>
</a>
<a href="/contact" class="nav-link">
<i data-lucide="mail" class="w-4 h-4"></i>
<span>Contact</span>
</a>
</div>
<!-- CTA Button & Mobile Menu -->
<div class="flex items-center space-x-2">
<!-- Donation Button -->
<a href="https://adventistgiving.org/donate/AN4MJG" target="_blank" rel="noopener" class="hidden sm:inline-flex items-center space-x-2 bg-gold-500 text-black px-4 py-2 rounded-xl font-medium shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105">
<i data-lucide="heart" class="w-4 h-4"></i>
<span>Donate</span>
</a>
<!-- Live Stream Button -->
<a href="/live" class="hidden sm:inline-flex items-center space-x-2 bg-sacred-gradient text-white px-4 py-2 rounded-xl font-medium shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105">
<i data-lucide="video" class="w-4 h-4"></i>
<span>Watch Live</span>
</a>
<!-- Mobile Menu Button -->
<button id="mobile-menu-button" class="lg:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<i data-lucide="menu" class="w-6 h-6"></i>
</button>
</div>
</div>
<!-- Mobile Menu -->
<div id="mobile-menu" class="hidden lg:hidden absolute top-full left-0 right-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-md border-b border-gray-200/20 dark:border-gray-700/20 shadow-xl">
<div class="px-4 py-6 space-y-3">
<a href="/" class="mobile-nav-link">
<i data-lucide="home" class="w-5 h-5"></i>
<span>Home</span>
</a>
<a href="/events" class="mobile-nav-link">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span>Events</span>
</a>
<a href="/sermons" class="mobile-nav-link">
<i data-lucide="book-open" class="w-5 h-5"></i>
<span>Sermons</span>
</a>
<a href="/bulletin" class="mobile-nav-link">
<i data-lucide="newspaper" class="w-5 h-5"></i>
<span>Current Bulletin</span>
</a>
<a href="/bulletin/archive" class="mobile-nav-link">
<i data-lucide="archive" class="w-5 h-5"></i>
<span>Bulletin Archive</span>
</a>
<a href="/three-angels" class="mobile-nav-link">
<i data-lucide="users" class="w-5 h-5"></i>
<span>Three Angels' Message</span>
</a>
<a href="/about" class="mobile-nav-link">
<i data-lucide="info" class="w-5 h-5"></i>
<span>About</span>
</a>
<a href="/contact" class="mobile-nav-link">
<i data-lucide="mail" class="w-5 h-5"></i>
<span>Contact</span>
</a>
<a href="https://adventistgiving.org/donate/AN4MJG" target="_blank" rel="noopener" class="mobile-nav-link bg-gold-500 text-black">
<i data-lucide="heart" class="w-5 h-5"></i>
<span>Donate</span>
</a>
<a href="/live" class="mobile-nav-link bg-sacred-gradient text-white">
<i data-lucide="video" class="w-5 h-5"></i>
<span>Watch Live</span>
</a>
</div>
</div>
</div>
</nav>
<style>
.nav-link {
@apply flex items-center space-x-1 px-2 py-2 rounded-lg text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200 font-medium text-sm;
}
.dropdown-menu {
@apply absolute top-full left-0 mt-1 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 transform translate-y-2 group-hover:translate-y-0 z-50;
}
.dropdown-item {
@apply flex items-center space-x-4 px-6 py-3 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors first:rounded-t-xl last:rounded-b-xl;
}
.mobile-nav-link {
@apply flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200 font-medium;
}
</style>

View file

@ -0,0 +1,59 @@
---
// Admin Login component - Tailwind CSS version
---
<div id="login" class="min-h-screen bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl p-8 border border-gray-100 dark:border-gray-700">
<div class="text-center mb-8">
<div class="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<span class="text-2xl text-white">⛪</span>
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Church Admin</h1>
<p class="text-gray-600 dark:text-gray-300">Manage your church events</p>
</div>
<form class="space-y-6" onsubmit="event.preventDefault(); handleLogin();">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Username
</label>
<input
id="username"
type="text"
value="admin"
required
autocomplete="username"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<input
id="password"
type="password"
required
autocomplete="current-password"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
>
</div>
<button
type="submit"
class="w-full bg-primary-600 hover:bg-primary-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-800"
>
Sign In to Dashboard
</button>
</form>
<div
id="loginError"
class="hidden mt-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-red-600 dark:text-red-400 text-center font-medium"
role="alert"
></div>
</div>
</div>
</div>

View file

@ -0,0 +1,218 @@
---
export interface Props {
title: string;
description?: string;
}
const { title, description = 'Proclaiming the Three Angels\' Message with love and truth' } = Astro.props;
---
<!doctype html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<!-- Preload Critical Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Playfair+Display:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<!-- Custom Styles for modern design -->
<style>
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #6366f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #4f46e5;
}
/* Smooth animations */
* {
transition: all 0.2s ease-in-out;
}
/* Focus states for accessibility */
:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
/* Custom gradient backgrounds */
.bg-divine-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.bg-heavenly-gradient {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%);
}
.bg-sacred-gradient {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
/* Text gradient utilities */
.text-divine-gradient {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-golden-gradient {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-track {
background: #1e293b;
}
}
/* Modern glass morphism effects */
.glass {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.glass-dark {
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Custom animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* Loading state */
.loading-shimmer {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 font-body text-gray-800 dark:text-gray-100 antialiased">
<!-- Background pattern overlay -->
<div class="fixed inset-0 bg-[url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%236366f1" fill-opacity="0.03"%3E%3Ccircle cx="30" cy="30" r="1"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] pointer-events-none"></div>
<!-- Main content -->
<div class="relative z-10">
<slot />
</div>
<!-- Mobile menu toggle script -->
<script>
document.addEventListener('DOMContentLoaded', () => {
// Mobile menu toggle
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
if (mobileMenuButton && mobileMenu) {
mobileMenuButton.addEventListener('click', () => {
const isOpen = mobileMenu.classList.contains('hidden');
if (isOpen) {
mobileMenu.classList.remove('hidden');
mobileMenu.classList.add('animate-fade-in-up');
} else {
mobileMenu.classList.add('hidden');
mobileMenu.classList.remove('animate-fade-in-up');
}
});
}
// Initialize Lucide icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Intersection Observer for animations
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-fade-in-up');
}
});
}, observerOptions);
// Observe elements with data-animate attribute
document.querySelectorAll('[data-animate]').forEach(el => {
observer.observe(el);
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,20 @@
---
import BaseLayout from './BaseLayout.astro';
import Navbar from '../components/Navbar.astro';
import Footer from '../components/Footer.astro';
export interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props;
---
<BaseLayout title={title} description={description}>
<Navbar />
<main class="flex-1">
<slot />
</main>
<Footer />
</BaseLayout>

View file

@ -0,0 +1,134 @@
use napi_derive::napi;
use church_core;
#[napi]
pub fn get_church_name() -> String {
church_core::get_church_name()
}
#[napi]
pub fn fetch_events_json() -> String {
church_core::fetch_events_json()
}
#[napi]
pub fn fetch_featured_events_json() -> String {
church_core::fetch_featured_events_json()
}
#[napi]
pub fn fetch_sermons_json() -> String {
church_core::fetch_sermons_json()
}
#[napi]
pub fn fetch_config_json() -> String {
church_core::fetch_config_json()
}
#[napi]
pub fn get_mission_statement() -> String {
church_core::get_mission_statement()
}
#[napi]
pub fn fetch_random_bible_verse_json() -> String {
church_core::fetch_random_bible_verse_json()
}
#[napi]
pub fn get_stream_live_status() -> bool {
church_core::get_stream_live_status()
}
#[napi]
pub fn get_livestream_url() -> String {
church_core::get_livestream_url()
}
#[napi]
pub fn get_church_address() -> String {
church_core::get_church_address()
}
#[napi]
pub fn get_contact_phone() -> String {
church_core::get_contact_phone()
}
#[napi]
pub fn get_contact_email() -> String {
church_core::get_contact_email()
}
#[napi]
pub fn get_facebook_url() -> String {
church_core::get_facebook_url()
}
#[napi]
pub fn get_youtube_url() -> String {
church_core::get_youtube_url()
}
#[napi]
pub fn get_instagram_url() -> String {
church_core::get_instagram_url()
}
#[napi]
pub fn submit_contact_v2_json(name: String, email: String, subject: String, message: String, phone: String) -> String {
church_core::submit_contact_v2_json(name, email, subject, message, phone)
}
#[napi]
pub fn validate_contact_form_json(form_json: String) -> String {
church_core::validate_contact_form_json(form_json)
}
#[napi]
pub fn fetch_livestream_archive_json() -> String {
church_core::fetch_livestream_archive_json()
}
#[napi]
pub fn fetch_bulletins_json() -> String {
church_core::fetch_bulletins_json()
}
#[napi]
pub fn fetch_current_bulletin_json() -> String {
church_core::fetch_current_bulletin_json()
}
#[napi]
pub fn fetch_bible_verse_json(query: String) -> String {
church_core::fetch_bible_verse_json(query)
}
#[napi]
pub fn submit_event_json(
title: String,
description: String,
start_time: String,
end_time: String,
location: String,
location_url: Option<String>,
category: String,
recurring_type: Option<String>,
submitter_email: Option<String>
) -> String {
church_core::submit_event_json(
title,
description,
start_time,
end_time,
location,
location_url,
category,
recurring_type,
submitter_email
)
}
// Admin functions removed due to API changes

View file

@ -0,0 +1,31 @@
// First, rename the CommonJS file to .cjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
// Require the CommonJS module
const nativeBindings = require('../../index.cjs');
export const {
getChurchName,
fetchEventsJson,
fetchFeaturedEventsJson,
fetchSermonsJson,
fetchLivestreamArchiveJson,
fetchConfigJson,
getMissionStatement,
fetchRandomBibleVerseJson,
getStreamLiveStatus,
getLivestreamUrl,
getChurchAddress,
getContactPhone,
getContactEmail,
getFacebookUrl,
getYoutubeUrl,
getInstagramUrl,
submitContactV2Json,
validateContactFormJson,
fetchBulletinsJson,
fetchCurrentBulletinJson,
fetchBibleVerseJson,
submitEventJson
} = nativeBindings;

View file

@ -0,0 +1,12 @@
// Church service times and constants
export const SERVICE_TIMES = {
SABBATH_SCHOOL: '9:15 AM',
DIVINE_SERVICE: '11:00 AM',
PRAYER_MEETING: 'Wed 6:30 PM'
};
// Other church constants can go here
export const CHURCH_CONSTANTS = {
SABBATH_DAY: 'Saturday',
PRAYER_MEETING_DAY: 'Wednesday'
};

View file

@ -0,0 +1,184 @@
---
import MainLayout from '../layouts/MainLayout.astro';
import { getChurchName, getMissionStatement } from '../lib/bindings.js';
let churchName = 'Church';
let missionStatement = '';
try {
churchName = getChurchName();
missionStatement = getMissionStatement();
} catch (e) {
console.error('Failed to get church info:', e);
}
---
<MainLayout title={`About - ${churchName}`} description="Learn about our Seventh-day Adventist church, our beliefs, and our commitment to the Three Angels' Message">
<!-- About Hero -->
<section class="py-16 bg-heavenly-gradient text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl lg:text-6xl font-bold mb-6">About Us</h1>
<p class="text-xl text-blue-100 max-w-2xl mx-auto">
A Seventh-day Adventist community committed to sharing God's love and the Three Angels' Message
</p>
</div>
</section>
<!-- Mission & Vision -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<div>
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">Our Mission</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 leading-relaxed mb-6">
{missionStatement || "We are called to proclaim the everlasting gospel and prepare people for the Second Coming of Jesus Christ through the Three Angels' Message."}
</p>
<p class="text-gray-600 dark:text-gray-300 leading-relaxed">
As Seventh-day Adventists, we believe in the Bible as God's Word, salvation by grace through faith, and the importance of living according to God's commandments, including the observance of the seventh-day Sabbath.
</p>
</div>
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<div class="text-center">
<div class="w-20 h-20 bg-gradient-to-br from-primary-500 to-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="heart" class="w-10 h-10 text-white"></i>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Our Vision</h3>
<p class="text-gray-600 dark:text-gray-300 leading-relaxed">
To be a loving, welcoming community that reflects Christ's character and prepares hearts for His soon return.
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Core Beliefs -->
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">Our Core Beliefs</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
Founded on Scripture and centered on Christ's saving grace
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<i data-lucide="book-open" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Scripture</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">The Bible is God's inspired Word and our only rule of faith and practice.</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-gradient-to-br from-gold-500 to-gold-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<i data-lucide="cross" class="w-8 h-8 text-black"></i>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Salvation</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">Salvation is by grace through faith in Jesus Christ alone.</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<i data-lucide="sun" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Sabbath</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">The seventh-day Sabbath is a gift from God for rest and worship.</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:shadow-lg transition-shadow">
<div class="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<i data-lucide="crown" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Second Coming</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">Jesus will return personally and visibly to take His people home.</p>
</div>
</div>
</div>
</section>
<!-- What We Offer -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">What We Offer</h2>
<p class="text-xl text-gray-600 dark:text-gray-300">
Programs and ministries to strengthen your faith journey
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<div class="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mb-6">
<i data-lucide="users" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Sabbath School</h3>
<p class="text-gray-600 dark:text-gray-300">Interactive Bible study classes for all ages every Sabbath morning.</p>
</div>
<div class="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<div class="w-16 h-16 bg-purple-500 rounded-2xl flex items-center justify-center mb-6">
<i data-lucide="heart" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Prayer Ministry</h3>
<p class="text-gray-600 dark:text-gray-300">Weekly prayer meetings and personal prayer support for our community.</p>
</div>
<div class="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<div class="w-16 h-16 bg-green-500 rounded-2xl flex items-center justify-center mb-6">
<i data-lucide="helping-hand" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Community Service</h3>
<p class="text-gray-600 dark:text-gray-300">Outreach programs to serve our neighbors with Christ's love.</p>
</div>
<div class="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<div class="w-16 h-16 bg-amber-500 rounded-2xl flex items-center justify-center mb-6">
<i data-lucide="book" class="w-8 h-8 text-black"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Bible Studies</h3>
<p class="text-gray-600 dark:text-gray-300">Personal and group Bible studies to deepen your understanding of Scripture.</p>
</div>
<div class="bg-gradient-to-br from-rose-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<div class="w-16 h-16 bg-rose-500 rounded-2xl flex items-center justify-center mb-6">
<i data-lucide="music" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Music Ministry</h3>
<p class="text-gray-600 dark:text-gray-300">Choir, special music, and opportunities to serve through musical talents.</p>
</div>
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<div class="w-16 h-16 bg-blue-500 rounded-2xl flex items-center justify-center mb-6">
<i data-lucide="baby" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Children's Ministry</h3>
<p class="text-gray-600 dark:text-gray-300">Age-appropriate programs to help children grow in their faith.</p>
</div>
</div>
</div>
</section>
<!-- Call to Action -->
<section class="py-16 bg-heavenly-gradient text-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl font-bold mb-6">Join Our Family</h2>
<p class="text-xl text-blue-100 mb-8">
Everyone is welcome in our church family. Come as you are and discover God's love for you.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" class="inline-flex items-center space-x-2 bg-gold-500 text-black px-8 py-4 rounded-2xl font-semibold hover:bg-gold-400 transition-colors">
<i data-lucide="mail" class="w-5 h-5"></i>
<span>Contact Us</span>
</a>
<a href="/live" class="inline-flex items-center space-x-2 bg-white/20 backdrop-blur-sm text-white px-8 py-4 rounded-2xl font-semibold border border-white/30 hover:bg-white/30 transition-colors">
<i data-lucide="video" class="w-5 h-5"></i>
<span>Watch Online</span>
</a>
</div>
</div>
</section>
</MainLayout>

View file

@ -0,0 +1,484 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import Login from '../../components/admin/Login.astro';
// Ensure this page uses server-side rendering
export const prerender = false;
---
<MainLayout title="Church Admin Dashboard" description="Administrative dashboard for church management">
<Login />
<div id="dashboard" class="hidden">
<!-- Header -->
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<span class="text-white text-lg">⛪</span>
</div>
<div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">Admin Dashboard</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Welcome back, Admin</div>
</div>
</div>
<button onclick="handleLogout()" class="inline-flex items-center space-x-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors">
<span>👋</span>
<span>Logout</span>
</button>
</div>
</div>
<!-- Main Content -->
<div class="max-w-6xl mx-auto p-6">
<!-- Stats Grid -->
<div id="statsGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Pending Events Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 cursor-pointer hover:shadow-lg transition-shadow" onclick="loadPending()">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Pending Events</h3>
<p id="pendingCount" class="text-2xl font-bold text-orange-600 dark:text-orange-400">-</p>
</div>
<div class="w-12 h-12 bg-orange-100 dark:bg-orange-900 rounded-lg flex items-center justify-center">
<span class="text-xl">⏳</span>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-orange-500 rounded-full"></div>
<span class="text-gray-600 dark:text-gray-400">Awaiting review</span>
</div>
<span class="text-gray-500 dark:text-gray-400">Click to view →</span>
</div>
</div>
<!-- Total Events Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 cursor-pointer hover:shadow-lg transition-shadow" onclick="loadAllEvents()">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Events</h3>
<p id="totalCount" class="text-2xl font-bold text-blue-600 dark:text-blue-400">-</p>
</div>
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<span class="text-xl">📅</span>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<span class="text-gray-600 dark:text-gray-400">Published events</span>
</div>
<span class="text-gray-500 dark:text-gray-400">Click to view →</span>
</div>
</div>
<!-- Bulletins Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 cursor-pointer hover:shadow-lg transition-shadow" onclick="showBulletins()">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Bulletins</h3>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">Manage</p>
</div>
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
<span class="text-xl">🗞️</span>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-purple-500 rounded-full"></div>
<span class="text-gray-600 dark:text-gray-400">Content management</span>
</div>
<span class="text-gray-500 dark:text-gray-400">Click to manage →</span>
</div>
</div>
<!-- Schedules Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 cursor-pointer hover:shadow-lg transition-shadow" onclick="showSchedules()">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Schedules</h3>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">Manage</p>
</div>
<div class="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<span class="text-xl">👥</span>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-gray-600 dark:text-gray-400">Personnel scheduling</span>
</div>
<span class="text-gray-500 dark:text-gray-400">Click to manage →</span>
</div>
</div>
</div>
<!-- Content Area -->
<div id="content" class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="text-center py-12">
<div class="text-4xl mb-4">🎉</div>
<p class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Welcome to the Admin Dashboard!</p>
<p class="text-gray-600 dark:text-gray-400">Click on the cards above to get started managing your church events.</p>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 id="modalTitle" class="text-xl font-semibold text-gray-900 dark:text-white"></h2>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-2xl">
×
</button>
</div>
<div id="modalContent" class="p-6">
<!-- Modal content populated by JS -->
</div>
<div id="modalActions" class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
<!-- Modal actions populated by JS -->
</div>
</div>
</div>
<!-- Include admin JavaScript -->
<script is:inline src="/admin/scripts/main.js"></script>
<!-- Debug script to check what's happening -->
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
console.log('Admin page loaded');
// Log what styles are being applied
setTimeout(() => {
const eventItems = document.querySelectorAll('.event-item');
console.log('Found event items:', eventItems.length);
eventItems.forEach((item, index) => {
console.log(`Event item ${index}:`, item);
console.log('Computed styles:', window.getComputedStyle(item));
});
}, 2000);
});
</script>
</MainLayout>
<style is:global>
.hidden {
display: none !important;
}
/* Basic styles for dynamically created content using standard CSS */
.btn-edit,
.btn-action.btn-edit {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: rgb(37 99 235);
color: white;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
text-decoration: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn-edit:hover,
.btn-action.btn-edit:hover {
background-color: rgb(29 78 216);
}
.btn-delete,
.btn-action.btn-delete {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: rgb(220 38 38);
color: white;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
text-decoration: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn-delete:hover,
.btn-action.btn-delete:hover {
background-color: rgb(185 28 28);
}
.btn-action {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
text-decoration: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn-action.btn-approve,
.btn-approve {
background-color: rgb(34 197 94);
color: white;
}
.btn-action.btn-approve:hover,
.btn-approve:hover {
background-color: rgb(21 128 61);
}
.btn-action.btn-reject,
.btn-reject {
background-color: rgb(220 38 38);
color: white;
}
.btn-action.btn-reject:hover,
.btn-reject:hover {
background-color: rgb(185 28 28);
}
.content-badge {
@apply px-3 py-1 rounded-full text-sm font-medium;
}
.content-badge.pending {
@apply bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200;
}
.content-badge.total {
@apply bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200;
}
.content-badge.purple {
@apply bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200;
}
.event-item {
background-color: rgb(31 41 55);
border-radius: 0.75rem;
border: 1px solid rgb(55 65 81);
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.event-content {
display: flex;
align-items: flex-start;
gap: 1.5rem;
}
.event-image {
width: 8rem;
height: 8rem;
background-color: rgb(55 65 81);
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.875rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid rgb(75 85 99);
}
.event-image img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.5rem;
}
.event-details {
flex: 1;
min-width: 0;
}
.event-title {
font-size: 1.25rem;
font-weight: 700;
color: rgb(243 244 246);
margin-bottom: 0.75rem;
line-height: 1.375;
}
.event-description {
color: rgb(156 163 175);
margin-bottom: 1rem;
line-height: 1.625;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.event-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
font-size: 0.875rem;
color: rgb(156 163 175);
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.meta-item span:first-child {
flex-shrink: 0;
}
.event-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
flex-shrink: 0;
margin-left: 1rem;
}
@media (max-width: 768px) {
.event-content {
flex-direction: column;
gap: 1rem;
}
.event-actions {
flex-direction: row;
margin-left: 0;
}
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.content-title {
font-size: 1.5rem;
font-weight: 700;
color: rgb(243 244 246);
}
.content-title span {
margin-right: 0.5rem;
}
.empty-state {
text-align: center;
padding: 3rem 0;
}
.empty-icon {
font-size: 2.25rem;
margin-bottom: 1rem;
}
.empty-icon.success {
color: rgb(34 197 94);
}
.empty-icon.info {
color: rgb(59 130 246);
}
.empty-icon.purple {
color: rgb(168 85 247);
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: rgb(243 244 246);
margin-bottom: 0.5rem;
}
.empty-state p:not(.empty-title) {
color: rgb(156 163 175);
}
.category-badge {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.category-badge.featured {
background-color: rgb(220 252 231);
color: rgb(22 101 52);
}
.category-badge.approved {
background-color: rgb(243 244 246);
color: rgb(55 65 81);
}
.category-badge.pending {
background-color: rgb(255 237 213);
color: rgb(154 52 18);
}
.form-grid {
@apply space-y-4;
}
.form-grid.cols-2 {
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
}
.form-grid label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2;
}
.form-grid input,
.form-grid textarea,
.form-grid select {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent;
}
.progress-bar {
@apply w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2;
}
.progress-fill {
@apply bg-blue-600 h-2 rounded-full transition-all duration-300;
}
/* Text truncation utility */
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Better text rendering for event descriptions */
.event-description p {
@apply mb-2;
}
.event-description p:last-child {
@apply mb-0;
}
/* Ensure text content doesn't break layout */
.event-details * {
word-wrap: break-word;
overflow-wrap: break-word;
}
</style>

View file

@ -0,0 +1,28 @@
import type { APIRoute } from 'astro';
import { fetchConfigJson } from '../../lib/bindings.js';
export const GET: APIRoute = async () => {
try {
// Call church-core UniFFI function directly
const configJson = fetchConfigJson();
const config = JSON.parse(configJson);
return new Response(JSON.stringify(config), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=600' // 10 minute cache for config
}
});
} catch (error) {
console.error('UniFFI API Route Error:', error);
return new Response(JSON.stringify({
error: 'Internal server error',
message: 'Failed to call church-core function'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View file

@ -0,0 +1,45 @@
import type { APIRoute } from 'astro';
import { submitContactV2Json, validateContactFormJson } from '../../lib/bindings.js';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { name, email, subject, message, phone } = body;
// Validate using church-core validation
const validationResult = validateContactFormJson(JSON.stringify(body));
const validation = JSON.parse(validationResult);
if (!validation.is_valid) {
return new Response(JSON.stringify({
success: false,
error: 'Validation failed',
details: validation.errors
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Submit contact form using church-core function
const resultJson = submitContactV2Json(name, email, subject, message, phone || '');
const result = JSON.parse(resultJson);
return new Response(JSON.stringify(result), {
status: result.success ? 200 : 400,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Contact API Error:', error);
return new Response(JSON.stringify({
success: false,
error: 'Failed to process contact form'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View file

@ -0,0 +1,29 @@
import type { APIRoute } from 'astro';
import { validateContactFormJson } from '../../../lib/bindings.js';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
// Validate using church-core validation
const validationResult = validateContactFormJson(JSON.stringify(body));
const validation = JSON.parse(validationResult);
return new Response(JSON.stringify(validation), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Validation API Error:', error);
return new Response(JSON.stringify({
is_valid: false,
errors: ['Validation service unavailable']
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View file

@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import { fetchEventsJson } from '../../lib/bindings.js';
export const GET: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const limit = url.searchParams.get('limit');
// Call church-core function directly via napi-rs
const eventsJson = fetchEventsJson();
const events = JSON.parse(eventsJson);
// Apply limit if specified
let filteredEvents = events;
if (limit) {
const limitNum = parseInt(limit, 10);
if (!isNaN(limitNum) && limitNum > 0) {
filteredEvents = events.slice(0, limitNum);
}
}
return new Response(JSON.stringify(filteredEvents), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300' // 5 minute cache
}
});
} catch (error) {
console.error('Native API Route Error:', error);
return new Response(JSON.stringify({
error: 'Internal server error',
message: 'Failed to call church-core function'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View file

@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import { fetchLivestreamArchiveJson } from '../../lib/bindings.js';
export const GET: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const limit = url.searchParams.get('limit');
// Call church-core UniFFI function directly
const livestreamsJson = fetchLivestreamArchiveJson();
const livestreams = JSON.parse(livestreamsJson);
// Apply limit if specified
let filteredLivestreams = livestreams;
if (limit) {
const limitNum = parseInt(limit, 10);
if (!isNaN(limitNum) && limitNum > 0) {
filteredLivestreams = livestreams.slice(0, limitNum);
}
}
return new Response(JSON.stringify(filteredLivestreams), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300' // 5 minute cache
}
});
} catch (error) {
console.error('Livestream Archive API Error:', error);
return new Response(JSON.stringify({
error: 'Internal server error',
message: 'Failed to call church-core function'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View file

@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import { fetchSermonsJson } from '../../lib/bindings.js';
export const GET: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const limit = url.searchParams.get('limit');
// Call church-core UniFFI function directly
const sermonsJson = fetchSermonsJson();
const sermons = JSON.parse(sermonsJson);
// Apply limit if specified
let filteredSermons = sermons;
if (limit) {
const limitNum = parseInt(limit, 10);
if (!isNaN(limitNum) && limitNum > 0) {
filteredSermons = sermons.slice(0, limitNum);
}
}
return new Response(JSON.stringify(filteredSermons), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300' // 5 minute cache
}
});
} catch (error) {
console.error('UniFFI API Route Error:', error);
return new Response(JSON.stringify({
error: 'Internal server error',
message: 'Failed to call church-core function'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View file

@ -0,0 +1,297 @@
---
import MainLayout from '../layouts/MainLayout.astro';
import { getChurchName, fetchCurrentBulletinJson, fetchBulletinsJson } from '../lib/bindings.js';
let churchName = 'Church';
let currentBulletin = null;
let bulletins = [];
try {
churchName = getChurchName();
// Get all bulletins first
const bulletinsJson = fetchBulletinsJson();
bulletins = JSON.parse(bulletinsJson);
// Find the most appropriate current bulletin
// Either today's bulletin or the next upcoming one
const today = new Date();
const todayString = today.toISOString().split('T')[0]; // YYYY-MM-DD format
// Sort bulletins by date (newest first)
const sortedBulletins = bulletins.sort((a, b) => new Date(b.date) - new Date(a.date));
// Find current bulletin: either today's or the next upcoming one
currentBulletin = sortedBulletins.find(bulletin => bulletin.date >= todayString);
// If no upcoming bulletin found, use the most recent one
if (!currentBulletin && sortedBulletins.length > 0) {
currentBulletin = sortedBulletins[0];
}
// Try the API's current bulletin as fallback
if (!currentBulletin) {
const currentBulletinJson = fetchCurrentBulletinJson();
currentBulletin = JSON.parse(currentBulletinJson);
}
console.log('📰 Successfully loaded bulletin data:', {
currentBulletin: currentBulletin ? `${currentBulletin.title} (${currentBulletin.date})` : 'none',
bulletinCount: bulletins.length,
todayDate: todayString
});
} catch (e) {
console.error('Failed to load bulletin data:', e);
}
// Format date from bulletin data
const formatBulletinDate = (dateString) => {
if (!dateString) return new Date().toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
const date = new Date(dateString + 'T00:00:00');
return date.toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
};
const displayDate = currentBulletin ? formatBulletinDate(currentBulletin.date) : formatBulletinDate(null);
const bulletinTitle = currentBulletin?.title || 'Weekly Bulletin';
// Parse service information
const parseSabbathSchool = (text) => {
if (!text) return null;
const lines = text.split('\n').filter(line => line.trim());
const result = {};
let currentKey = '';
lines.forEach(line => {
if (line.includes(':')) {
currentKey = line.replace(':', '').trim();
result[currentKey] = '';
} else if (currentKey && line.trim()) {
result[currentKey] = line.trim();
}
});
return result;
};
const parseDivineWorship = (text) => {
if (!text) return null;
const lines = text.split('\n').filter(line => line.trim());
const result = {};
let currentKey = '';
lines.forEach(line => {
if (line.includes(':')) {
currentKey = line.replace(':', '').trim();
result[currentKey] = '';
} else if (currentKey && line.trim()) {
if (result[currentKey]) {
result[currentKey] += '\n' + line.trim();
} else {
result[currentKey] = line.trim();
}
}
});
return result;
};
const sabbathSchoolInfo = currentBulletin ? parseSabbathSchool(currentBulletin.sabbath_school) : null;
const divineWorshipInfo = currentBulletin ? parseDivineWorship(currentBulletin.divine_worship) : null;
---
<MainLayout title={`Bulletin - ${churchName}`} description="Weekly church bulletin with service information, announcements, and upcoming events">
<!-- Bulletin Hero -->
<section class="py-16 bg-heavenly-gradient text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="newspaper" class="w-8 h-8 text-white"></i>
</div>
<h1 class="text-4xl lg:text-6xl font-bold mb-6">{bulletinTitle}</h1>
<p class="text-xl text-blue-100 max-w-2xl mx-auto">
Stay informed with our weekly church bulletin featuring service details, announcements, and community updates
</p>
<div class="mt-4 flex flex-col sm:flex-row items-center justify-center gap-4">
<span class="inline-block bg-white/20 px-4 py-2 rounded-xl text-blue-100 font-medium">
{displayDate}
</span>
{currentBulletin?.pdf_path && (
<a href={currentBulletin.pdf_path} target="_blank" rel="noopener" class="inline-flex items-center space-x-2 bg-white/20 hover:bg-white/30 px-4 py-2 rounded-xl text-blue-100 font-medium transition-colors">
<i data-lucide="download" class="w-4 h-4"></i>
<span>Download PDF</span>
</a>
)}
</div>
</div>
</section>
<!-- Service Information -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12">
<!-- Sabbath Services -->
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8">
<div class="flex items-center space-x-3 mb-6">
<div class="w-12 h-12 bg-primary-500 rounded-xl flex items-center justify-center">
<i data-lucide="sun" class="w-6 h-6 text-white"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Sabbath Services</h2>
</div>
<div class="space-y-6">
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Sabbath School</h3>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<i data-lucide="clock" class="w-5 h-5 text-primary-500"></i>
<span class="text-gray-600 dark:text-gray-300">9:30 AM - 10:45 AM</span>
</div>
{sabbathSchoolInfo && Object.entries(sabbathSchoolInfo).map(([key, value]) => (
<div class="flex items-start space-x-3">
<i data-lucide="user" class="w-5 h-5 text-primary-500 mt-0.5 flex-shrink-0"></i>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">{key}:</span>
<span class="text-gray-600 dark:text-gray-300 ml-2">{value}</span>
</div>
</div>
))}
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Divine Service</h3>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<i data-lucide="clock" class="w-5 h-5 text-primary-500"></i>
<span class="text-gray-600 dark:text-gray-300">11:00 AM - 12:15 PM</span>
</div>
{divineWorshipInfo && Object.entries(divineWorshipInfo).map(([key, value]) => (
<div class="flex items-start space-x-3">
<i data-lucide="mic" class="w-5 h-5 text-primary-500 mt-0.5 flex-shrink-0"></i>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">{key}:</span>
<div class="text-gray-600 dark:text-gray-300 ml-2 whitespace-pre-line">{value}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
<!-- This Week's Focus -->
<div class="bg-gradient-to-br from-gold-50 to-orange-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8">
<div class="flex items-center space-x-3 mb-6">
<div class="w-12 h-12 bg-gold-500 rounded-xl flex items-center justify-center">
<i data-lucide="star" class="w-6 h-6 text-black"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">This Week's Focus</h2>
</div>
<div class="space-y-6">
{currentBulletin?.scripture_reading && (
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Scripture Reading</h3>
<div class="text-gray-600 dark:text-gray-300 whitespace-pre-line">
{currentBulletin.scripture_reading}
</div>
</div>
)}
{divineWorshipInfo?.Sermon && (
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Sermon</h3>
<div class="text-gray-600 dark:text-gray-300 whitespace-pre-line">
{divineWorshipInfo.Sermon}
</div>
</div>
)}
{currentBulletin?.sunset && (
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Sunset Times</h3>
<div class="text-gray-600 dark:text-gray-300 whitespace-pre-line">
{currentBulletin.sunset}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</section>
<!-- Recent Bulletins -->
{bulletins && bulletins.length > 1 && (
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">Recent Bulletins</h2>
<p class="text-xl text-gray-600 dark:text-gray-300">View our previous weekly bulletins</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{bulletins.slice(0, 6).map((bulletin) => (
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 shadow-lg">
<div class="w-12 h-12 bg-primary-500 rounded-xl flex items-center justify-center mb-4">
<i data-lucide="calendar" class="w-6 h-6 text-white"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">{bulletin.title}</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4">
{formatBulletinDate(bulletin.date)}
</p>
<div class="flex gap-2">
<a href={`/bulletin/${bulletin.id}`} class="inline-flex items-center space-x-2 bg-primary-600 text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-primary-700 transition-colors">
<i data-lucide="eye" class="w-4 h-4"></i>
<span>View</span>
</a>
{bulletin.pdf_path && (
<a href={bulletin.pdf_path} target="_blank" rel="noopener" class="inline-flex items-center space-x-2 bg-gray-600 text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-gray-700 transition-colors">
<i data-lucide="download" class="w-4 h-4"></i>
<span>PDF</span>
</a>
)}
</div>
</div>
))}
</div>
{bulletins.length > 6 && (
<div class="text-center mt-12">
<a href="/bulletin/archive" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors">
<i data-lucide="archive" class="w-5 h-5"></i>
<span>View All Bulletins ({bulletins.length})</span>
</a>
</div>
)}
</div>
</section>
)}
<!-- Prayer Requests & Contact -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">Stay Connected</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-8">
Have prayer requests or questions? We're here for you.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors">
<i data-lucide="mail" class="w-5 h-5"></i>
<span>Contact Us</span>
</a>
<a href="/events" class="inline-flex items-center space-x-2 border-2 border-primary-600 text-primary-600 dark:text-primary-400 px-8 py-4 rounded-2xl font-semibold hover:bg-primary-600 hover:text-white transition-colors">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span>View Events</span>
</a>
</div>
</div>
</section>
</MainLayout>

View file

@ -0,0 +1,266 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { getChurchName, fetchBulletinsJson } from '../../lib/bindings.js';
// Get the bulletin ID from URL params
const { id } = Astro.params;
// Fetch all bulletins and find the one with matching ID
let bulletin = null;
try {
const bulletinsJson = fetchBulletinsJson();
const bulletins = JSON.parse(bulletinsJson);
bulletin = bulletins.find(b => b.id === id);
} catch (e) {
console.error('Failed to fetch bulletin:', e);
}
let churchName = 'Church';
try {
churchName = getChurchName();
} catch (e) {
console.error('Failed to get church name:', e);
}
// Format date from bulletin data
const formatBulletinDate = (dateString) => {
if (!dateString) return new Date().toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
const date = new Date(dateString + 'T00:00:00');
return date.toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
};
// Parse service information
const parseSabbathSchool = (text) => {
if (!text) return null;
const lines = text.split('\n').filter(line => line.trim());
const result = {};
let currentKey = '';
lines.forEach(line => {
if (line.includes(':')) {
currentKey = line.replace(':', '').trim();
result[currentKey] = '';
} else if (currentKey && line.trim()) {
result[currentKey] = line.trim();
}
});
return result;
};
const parseDivineWorship = (text) => {
if (!text) return null;
const lines = text.split('\n').filter(line => line.trim());
const result = {};
let currentKey = '';
lines.forEach(line => {
if (line.includes(':')) {
currentKey = line.replace(':', '').trim();
result[currentKey] = '';
} else if (currentKey && line.trim()) {
if (result[currentKey]) {
result[currentKey] += '\n' + line.trim();
} else {
result[currentKey] = line.trim();
}
}
});
return result;
};
const sabbathSchoolInfo = bulletin ? parseSabbathSchool(bulletin.sabbath_school) : null;
const divineWorshipInfo = bulletin ? parseDivineWorship(bulletin.divine_worship) : null;
const displayDate = bulletin ? formatBulletinDate(bulletin.date) : '';
---
<MainLayout title={`${bulletin?.title || 'Bulletin'} - ${churchName}`} description={`Digital version of the ${bulletin?.title} bulletin for ${displayDate}`}>
{bulletin ? (
<>
<!-- Bulletin Hero -->
<section class="py-16 bg-heavenly-gradient text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col lg:flex-row items-center gap-8">
<div class="flex-1 text-center lg:text-left">
<div class="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center mx-auto lg:mx-0 mb-6">
<i data-lucide="newspaper" class="w-8 h-8 text-white"></i>
</div>
<h1 class="text-4xl lg:text-6xl font-bold mb-4">{bulletin.title}</h1>
<p class="text-xl text-blue-100 mb-6">{displayDate}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
<a href="/bulletin" class="inline-flex items-center space-x-2 bg-white/20 hover:bg-white/30 px-6 py-3 rounded-xl text-blue-100 font-medium transition-colors">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
<span>Back to Current</span>
</a>
<a href="/bulletin/archive" class="inline-flex items-center space-x-2 bg-white/20 hover:bg-white/30 px-6 py-3 rounded-xl text-blue-100 font-medium transition-colors">
<i data-lucide="archive" class="w-4 h-4"></i>
<span>View Archive</span>
</a>
{bulletin.pdf_path && (
<a href={bulletin.pdf_path} target="_blank" rel="noopener" class="inline-flex items-center space-x-2 bg-gold-500 hover:bg-gold-600 text-black px-6 py-3 rounded-xl font-medium transition-colors">
<i data-lucide="download" class="w-4 h-4"></i>
<span>Download PDF</span>
</a>
)}
</div>
</div>
{bulletin.cover_image && (
<div class="lg:flex-shrink-0">
<img
src={bulletin.cover_image}
alt={`${bulletin.title} cover`}
class="w-full lg:w-80 h-auto max-h-96 object-contain rounded-2xl shadow-2xl border-4 border-white/20 bg-white/10"
onerror="this.style.display='none'; this.parentElement.style.display='none';"
/>
</div>
)}
</div>
</div>
</section>
<!-- Service Information -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12">
<!-- Sabbath Services -->
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8">
<div class="flex items-center space-x-3 mb-6">
<div class="w-12 h-12 bg-primary-500 rounded-xl flex items-center justify-center">
<i data-lucide="sun" class="w-6 h-6 text-white"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Sabbath Services</h2>
</div>
<div class="space-y-6">
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Sabbath School</h3>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<i data-lucide="clock" class="w-5 h-5 text-primary-500"></i>
<span class="text-gray-600 dark:text-gray-300">9:30 AM - 10:45 AM</span>
</div>
{sabbathSchoolInfo && Object.entries(sabbathSchoolInfo).map(([key, value]) => (
<div class="flex items-start space-x-3">
<i data-lucide="user" class="w-5 h-5 text-primary-500 mt-0.5 flex-shrink-0"></i>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">{key}:</span>
<span class="text-gray-600 dark:text-gray-300 ml-2">{value}</span>
</div>
</div>
))}
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Divine Service</h3>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<i data-lucide="clock" class="w-5 h-5 text-primary-500"></i>
<span class="text-gray-600 dark:text-gray-300">11:00 AM - 12:15 PM</span>
</div>
{divineWorshipInfo && Object.entries(divineWorshipInfo).map(([key, value]) => (
<div class="flex items-start space-x-3">
<i data-lucide="mic" class="w-5 h-5 text-primary-500 mt-0.5 flex-shrink-0"></i>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">{key}:</span>
<div class="text-gray-600 dark:text-gray-300 ml-2 whitespace-pre-line">{value}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
<!-- This Week's Focus -->
<div class="bg-gradient-to-br from-gold-50 to-orange-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8">
<div class="flex items-center space-x-3 mb-6">
<div class="w-12 h-12 bg-gold-500 rounded-xl flex items-center justify-center">
<i data-lucide="star" class="w-6 h-6 text-black"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">This Week's Focus</h2>
</div>
<div class="space-y-6">
{bulletin.scripture_reading && (
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Scripture Reading</h3>
<div class="text-gray-600 dark:text-gray-300 whitespace-pre-line">
{bulletin.scripture_reading}
</div>
</div>
)}
{divineWorshipInfo?.Sermon && (
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Sermon</h3>
<div class="text-gray-600 dark:text-gray-300 whitespace-pre-line">
{divineWorshipInfo.Sermon}
</div>
</div>
)}
{bulletin.sunset && (
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Sunset Times</h3>
<div class="text-gray-600 dark:text-gray-300 whitespace-pre-line">
{bulletin.sunset}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</section>
<!-- Contact Section -->
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">Stay Connected</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-8">
Have prayer requests or questions? We're here for you.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors">
<i data-lucide="mail" class="w-5 h-5"></i>
<span>Contact Us</span>
</a>
<a href="/events" class="inline-flex items-center space-x-2 border-2 border-primary-600 text-primary-600 dark:text-primary-400 px-8 py-4 rounded-2xl font-semibold hover:bg-primary-600 hover:text-white transition-colors">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span>View Events</span>
</a>
</div>
</div>
</section>
</>
) : (
<!-- 404 Section -->
<section class="py-20 bg-white dark:bg-gray-900">
<div class="max-w-md mx-auto text-center">
<div class="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="file-x" class="w-8 h-8 text-gray-400"></i>
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Bulletin Not Found</h1>
<p class="text-gray-600 dark:text-gray-300 mb-8">
The bulletin you're looking for could not be found.
</p>
<a href="/bulletin" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-6 py-3 rounded-xl font-medium hover:bg-primary-700 transition-colors">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
<span>Back to Bulletins</span>
</a>
</div>
</section>
)}
</MainLayout>

View file

@ -0,0 +1,203 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { getChurchName, fetchBulletinsJson } from '../../lib/bindings.js';
let churchName = 'Church';
let bulletins = [];
try {
churchName = getChurchName();
// Get all bulletins
const bulletinsJson = fetchBulletinsJson();
bulletins = JSON.parse(bulletinsJson);
// Sort bulletins by date (newest first)
bulletins = bulletins.sort((a, b) => new Date(b.date) - new Date(a.date));
console.log('📰 Loaded bulletin archive:', {
bulletinCount: bulletins.length
});
} catch (e) {
console.error('Failed to load bulletin archive:', e);
}
// Format date from bulletin data
const formatBulletinDate = (dateString) => {
if (!dateString) return new Date().toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
const date = new Date(dateString + 'T00:00:00');
return date.toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
};
// Group bulletins by year
const bulletinsByYear = bulletins.reduce((acc, bulletin) => {
const year = new Date(bulletin.date).getFullYear();
if (!acc[year]) acc[year] = [];
acc[year].push(bulletin);
return acc;
}, {});
const years = Object.keys(bulletinsByYear).sort((a, b) => b - a); // newest year first
---
<MainLayout title={`Bulletin Archive - ${churchName}`} description="Complete archive of weekly church bulletins with digital viewing">
<!-- Archive Hero -->
<section class="py-16 bg-heavenly-gradient text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="archive" class="w-8 h-8 text-white"></i>
</div>
<h1 class="text-4xl lg:text-6xl font-bold mb-6">Bulletin Archive</h1>
<p class="text-xl text-blue-100 max-w-2xl mx-auto">
Browse our complete collection of weekly bulletins - {bulletins.length} bulletins available
</p>
<div class="mt-6">
<a href="/bulletin" class="inline-flex items-center space-x-2 bg-white/20 hover:bg-white/30 px-6 py-3 rounded-xl text-blue-100 font-medium transition-colors">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
<span>Back to Current Bulletin</span>
</a>
</div>
</div>
</section>
<!-- Search and Filter -->
<section class="py-8 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row gap-4 items-center">
<div class="flex-1 max-w-md">
<div class="relative">
<i data-lucide="search" class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"></i>
<input
type="text"
id="bulletin-search"
placeholder="Search bulletins by title..."
class="w-full pl-10 pr-4 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
</div>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Showing {bulletins.length} bulletins
</div>
</div>
</div>
</section>
<!-- Bulletin Archive by Year -->
{years.map((year) => (
<section class="py-12 bg-gray-50 dark:bg-gray-800" id={`year-${year}`}>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">{year}</h2>
<span class="text-sm text-gray-500 dark:text-gray-400">{bulletinsByYear[year].length} bulletins</span>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{bulletinsByYear[year].map((bulletin) => (
<div class="bulletin-card bg-white dark:bg-gray-900 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
{bulletin.cover_image && (
<div class="mb-4 rounded-xl overflow-hidden bg-gray-100 dark:bg-gray-800">
<img
src={bulletin.cover_image}
alt={`${bulletin.title} cover`}
class="w-full h-32 object-cover"
loading="lazy"
onerror="this.parentElement.style.display='none';"
/>
</div>
)}
<div class="flex items-center space-x-3 mb-4">
<div class="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i data-lucide="calendar" class="w-5 h-5 text-white"></i>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400 font-medium">
{formatBulletinDate(bulletin.date)}
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 line-clamp-2 bulletin-title">
{bulletin.title}
</h3>
<div class="flex gap-2 mt-auto">
<a
href={`/bulletin/${bulletin.id}`}
class="flex-1 inline-flex items-center justify-center space-x-2 bg-primary-600 text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-primary-700 transition-colors"
>
<i data-lucide="eye" class="w-4 h-4"></i>
<span>View</span>
</a>
{bulletin.pdf_path && (
<a
href={bulletin.pdf_path}
target="_blank"
rel="noopener"
class="inline-flex items-center justify-center space-x-2 bg-gray-600 text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-gray-700 transition-colors"
title="Download PDF"
>
<i data-lucide="download" class="w-4 h-4"></i>
</a>
)}
</div>
</div>
))}
</div>
</div>
</section>
))}
<!-- Empty State -->
{bulletins.length === 0 && (
<section class="py-20 bg-white dark:bg-gray-900">
<div class="max-w-md mx-auto text-center">
<div class="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="inbox" class="w-8 h-8 text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">No Bulletins Found</h3>
<p class="text-gray-600 dark:text-gray-300">
We couldn't find any bulletins to display. Please check back later.
</p>
</div>
</section>
)}
</MainLayout>
<script>
// Simple search functionality
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('bulletin-search');
const bulletinCards = document.querySelectorAll('.bulletin-card');
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
bulletinCards.forEach(card => {
const title = card.querySelector('.bulletin-title');
const titleText = title ? title.textContent.toLowerCase() : '';
if (titleText.includes(searchTerm)) {
card.style.display = 'block';
} else {
card.style.display = 'none';
}
});
});
}
});
</script>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,417 @@
---
import MainLayout from '../layouts/MainLayout.astro';
import { SERVICE_TIMES } from '../lib/constants.js';
import {
getChurchName,
getChurchAddress,
getContactPhone,
getContactEmail
} from '../lib/bindings.js';
let churchName = 'Church';
let address = '';
let phone = '';
let email = '';
try {
churchName = getChurchName();
address = getChurchAddress();
phone = getContactPhone();
// Get the base email from church-core and make it dynamic based on current domain
const baseEmail = getContactEmail();
const currentUrl = Astro.url.hostname;
// Extract the domain part and create dynamic email
if (currentUrl && currentUrl !== 'localhost') {
email = `admin@${currentUrl}`;
} else {
// Fallback to the original email from church-core
email = baseEmail;
}
} catch (e) {
console.error('Failed to get contact info:', e);
}
---
<MainLayout title={`Contact - ${churchName}`} description="Get in touch with us for questions, prayer requests, or to learn more about our faith">
<!-- Contact Hero -->
<section class="py-16 bg-heavenly-gradient text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl lg:text-6xl font-bold mb-6">Contact Us</h1>
<p class="text-xl text-blue-100 max-w-2xl mx-auto">
We'd love to hear from you. Reach out with questions, prayer requests, or to learn more about our faith community.
</p>
</div>
</section>
<!-- Contact Content -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12">
<!-- Contact Form -->
<div class="bg-gradient-to-br from-gray-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">Send us a message</h2>
<form id="contactForm" class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Name *</label>
<input
type="text"
id="name"
name="name"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="Your full name"
>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Email *</label>
<input
type="email"
id="email"
name="email"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="your.email@example.com"
>
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Phone</label>
<input
type="tel"
id="phone"
name="phone"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="(555) 123-4567"
>
</div>
<div>
<label for="subject" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Subject *</label>
<select
id="subject"
name="subject"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">Select a subject</option>
<option value="General Inquiry">General Inquiry</option>
<option value="Prayer Request">Prayer Request</option>
<option value="Bible Study">Bible Study Questions</option>
<option value="Three Angels Message">Three Angels' Message</option>
<option value="Pastoral Care">Pastoral Care</option>
<option value="Events">Events & Activities</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Message *</label>
<textarea
id="message"
name="message"
required
rows="5"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
placeholder="How can we help you today?"
></textarea>
</div>
<button
type="submit"
class="w-full bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors flex items-center justify-center space-x-2"
>
<i data-lucide="send" class="w-5 h-5"></i>
<span>Send Message</span>
</button>
</form>
<div id="formMessage" class="mt-4 hidden"></div>
</div>
<!-- Contact Information -->
<div class="space-y-8">
<div>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">Get in touch</h2>
<p class="text-gray-600 dark:text-gray-300 text-lg leading-relaxed">
Whether you're seeking spiritual guidance, have questions about our faith, or want to join our community, we're here to help.
</p>
</div>
<!-- Contact Details -->
<div class="space-y-6">
{address && (
<div class="flex items-start space-x-4">
<div class="w-12 h-12 bg-primary-500 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="map-pin" class="w-6 h-6 text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Visit Us</h3>
<p class="text-gray-600 dark:text-gray-300">{address}</p>
</div>
</div>
)}
{phone && (
<div class="flex items-start space-x-4">
<div class="w-12 h-12 bg-gold-500 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="phone" class="w-6 h-6 text-black"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Call Us</h3>
<a href={`tel:${phone}`} class="text-primary-600 dark:text-primary-400 hover:underline">{phone}</a>
</div>
</div>
)}
{email && (
<div class="flex items-start space-x-4">
<div class="w-12 h-12 bg-purple-500 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="mail" class="w-6 h-6 text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Email Us</h3>
<a href={`mailto:${email}`} class="text-primary-600 dark:text-primary-400 hover:underline">{email}</a>
</div>
</div>
)}
<div class="flex items-start space-x-4">
<div class="w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center flex-shrink-0">
<i data-lucide="clock" class="w-6 h-6 text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Service Times</h3>
<div class="text-gray-600 dark:text-gray-300 space-y-1">
<p>Sabbath School: {SERVICE_TIMES.SABBATH_SCHOOL}</p>
<p>Divine Service: {SERVICE_TIMES.DIVINE_SERVICE}</p>
<p>Prayer Meeting: {SERVICE_TIMES.PRAYER_MEETING}</p>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Quick Links</h3>
<div class="space-y-3">
<a href="/three-angels" class="flex items-center space-x-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors">
<i data-lucide="users" class="w-4 h-4"></i>
<span>Learn about the Three Angels' Message</span>
</a>
<a href="/events" class="flex items-center space-x-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>View upcoming events</span>
</a>
<a href="/sermons" class="flex items-center space-x-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors">
<i data-lucide="play" class="w-4 h-4"></i>
<span>Listen to recent sermons</span>
</a>
</div>
</div>
</div>
</div>
</div>
</section>
</MainLayout>
<script>
let validationTimeout: number;
// Real-time validation function
async function validateForm() {
const form = document.getElementById('contactForm') as HTMLFormElement;
if (!form) return;
const formData = new FormData(form);
const data = {
name: formData.get('name'),
email: formData.get('email'),
phone: formData.get('phone'),
subject: formData.get('subject'),
message: formData.get('message')
};
try {
const response = await fetch('/api/contact/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
const result = await response.json();
// Clear previous validation messages
document.querySelectorAll('.field-error').forEach(el => el.remove());
document.querySelectorAll('.border-red-500').forEach(el => {
el.classList.remove('border-red-500');
el.classList.add('border-gray-300', 'dark:border-gray-600');
});
if (!result.is_valid && result.errors.length > 0) {
// Show field-specific errors
result.errors.forEach((error: string) => {
let fieldName = '';
if (error.toLowerCase().includes('name')) fieldName = 'name';
else if (error.toLowerCase().includes('email')) fieldName = 'email';
else if (error.toLowerCase().includes('phone')) fieldName = 'phone';
else if (error.toLowerCase().includes('subject')) fieldName = 'subject';
else if (error.toLowerCase().includes('message')) fieldName = 'message';
if (fieldName) {
const field = document.getElementById(fieldName);
if (field) {
field.classList.remove('border-gray-300', 'dark:border-gray-600');
field.classList.add('border-red-500');
const errorDiv = document.createElement('div');
errorDiv.className = 'field-error text-red-600 dark:text-red-400 text-sm mt-1';
errorDiv.textContent = error;
field.parentNode?.appendChild(errorDiv);
}
}
});
}
} catch (error) {
console.error('Validation error:', error);
}
}
// Phone number formatting
function formatPhoneNumber(value: string): string {
// Remove all non-digits
const phoneNumber = value.replace(/\D/g, '');
// Format based on length
if (phoneNumber.length === 10) {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6)}`;
} else if (phoneNumber.length === 11 && phoneNumber[0] === '1') {
return `+1 (${phoneNumber.slice(1, 4)}) ${phoneNumber.slice(4, 7)}-${phoneNumber.slice(7)}`;
}
return value; // Return original if not a standard format
}
function unformatPhoneNumber(value: string): string {
return value.replace(/\D/g, '');
}
// Add real-time validation to form fields
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('contactForm');
if (!form) return;
const phoneField = document.getElementById('phone') as HTMLInputElement;
let isTyping = false;
let typingTimeout: number;
const fields = ['name', 'email', 'phone', 'subject', 'message'];
fields.forEach(fieldName => {
const field = document.getElementById(fieldName);
if (field) {
field.addEventListener('input', () => {
clearTimeout(validationTimeout);
validationTimeout = setTimeout(validateForm, 500);
});
field.addEventListener('blur', validateForm);
}
});
// Special handling for phone field
if (phoneField) {
phoneField.addEventListener('input', () => {
isTyping = true;
clearTimeout(typingTimeout);
// If user is typing, remove formatting
if (isTyping) {
const unformatted = unformatPhoneNumber(phoneField.value);
if (phoneField.value !== unformatted && phoneField.value.length > unformatted.length) {
phoneField.value = unformatted;
}
}
// Set timeout to format when user stops typing
typingTimeout = setTimeout(() => {
isTyping = false;
if (phoneField.value.trim()) {
phoneField.value = formatPhoneNumber(phoneField.value);
}
}, 1000);
});
phoneField.addEventListener('blur', () => {
isTyping = false;
clearTimeout(typingTimeout);
if (phoneField.value.trim()) {
phoneField.value = formatPhoneNumber(phoneField.value);
}
});
phoneField.addEventListener('focus', () => {
isTyping = true;
clearTimeout(typingTimeout);
});
}
});
document.getElementById('contactForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const messageDiv = document.getElementById('formMessage');
if (!messageDiv) return;
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formData.get('name'),
email: formData.get('email'),
phone: formData.get('phone'),
subject: formData.get('subject'),
message: formData.get('message')
})
});
const result = await response.json();
if (response.ok && result.success) {
messageDiv.className = 'mt-4 p-4 bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-xl';
messageDiv.textContent = 'Thank you! Your message has been sent successfully.';
(e.target as HTMLFormElement).reset();
// Clear validation errors on successful submit
document.querySelectorAll('.field-error').forEach(el => el.remove());
document.querySelectorAll('.border-red-500').forEach(el => {
el.classList.remove('border-red-500');
el.classList.add('border-gray-300', 'dark:border-gray-600');
});
} else {
messageDiv.className = 'mt-4 p-4 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-xl';
messageDiv.textContent = result.error || result.message || 'There was an error sending your message. Please try again.';
}
messageDiv.classList.remove('hidden');
setTimeout(() => {
messageDiv.classList.add('hidden');
}, 5000);
} catch (error) {
messageDiv.className = 'mt-4 p-4 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-xl';
messageDiv.textContent = 'Network error. Please check your connection and try again.';
messageDiv.classList.remove('hidden');
}
});
</script>

View file

@ -0,0 +1,167 @@
---
import MainLayout from '../layouts/MainLayout.astro';
import { getChurchName, fetchEventsJson } from '../lib/bindings.js';
let churchName = 'Church';
let events = [];
try {
churchName = getChurchName();
// Get upcoming events from V2 API
const eventsJson = fetchEventsJson();
console.log('Raw events JSON:', eventsJson);
const parsedEvents = JSON.parse(eventsJson);
console.log('Parsed events:', parsedEvents);
events = Array.isArray(parsedEvents) ? parsedEvents : (parsedEvents.items || []);
console.log('Final events array:', events.length, 'events');
// Fallback: If no events from binding, try direct API call
if (events.length === 0) {
console.log('No events from binding, trying direct API call...');
try {
const response = await fetch('https://api.rockvilletollandsda.church/api/events');
const apiData = await response.json();
if (apiData.success && apiData.data && apiData.data.items) {
events = apiData.data.items;
console.log('Loaded', events.length, 'events from direct API call');
}
} catch (apiError) {
console.error('Direct API call failed:', apiError);
}
}
} catch (e) {
console.error('Failed to load events:', e);
}
---
<MainLayout title={`Events - ${churchName}`} description="Join us for upcoming church events and fellowship">
<!-- Events Hero -->
<section class="py-16 bg-heavenly-gradient text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl lg:text-6xl font-bold mb-6">Church Events</h1>
<p class="text-xl text-blue-100 max-w-2xl mx-auto mb-8">
Join us for worship, fellowship, and spiritual growth in our community
</p>
<div class="flex justify-center">
<a href="/events/submit" class="inline-flex items-center space-x-2 bg-gold-500 hover:bg-gold-600 text-black px-8 py-4 rounded-2xl font-semibold transition-colors shadow-lg hover:shadow-xl hover:scale-105 transform duration-300">
<i data-lucide="plus" class="w-5 h-5"></i>
<span>Submit Event</span>
</a>
</div>
</div>
</section>
<!-- Events Grid -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{events.length > 0 ? (
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{events.map(event => (
<div class="bg-gradient-to-br from-gray-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl overflow-hidden shadow-medium hover:shadow-large transition-all duration-300 hover:-translate-y-1 group" data-animate>
{event.image && (
<div class="aspect-video overflow-hidden">
<img src={event.image} alt={event.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
</div>
)}
<div class="p-6">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">{event.title}</h3>
<div class="space-y-2 mb-4">
{event.formatted_date && (
<div class="flex items-center space-x-2 text-primary-600 dark:text-primary-400">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span class="text-sm font-medium">{event.formatted_date}</span>
</div>
)}
{event.formatted_time && (
<div class="flex items-center space-x-2 text-gray-600 dark:text-gray-300">
<i data-lucide="clock" class="w-4 h-4"></i>
<span class="text-sm">{
// For multi-day events, extract just the time part
event.formatted_time.includes(' - ') && event.formatted_time.includes(',')
? event.formatted_time.split(', ').pop()
: event.formatted_time
}</span>
</div>
)}
{event.location && (
<div class="flex items-center space-x-2 text-gray-600 dark:text-gray-300">
<i data-lucide="map-pin" class="w-4 h-4"></i>
<span class="text-sm">{event.location}</span>
</div>
)}
</div>
{event.description && (
<p class="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed text-sm line-clamp-3">{event.description}</p>
)}
<div class="flex space-x-2">
<a href={`/events/${event.id}`} class="flex-1 inline-flex items-center justify-center space-x-2 bg-primary-600 text-white px-4 py-2 rounded-xl font-medium hover:bg-primary-700 transition-colors text-sm">
<i data-lucide="info" class="w-4 h-4"></i>
<span>Details</span>
</a>
{event.registration_url && (
<a href={event.registration_url} target="_blank" rel="noopener" class="inline-flex items-center justify-center p-2 bg-gold-500 text-black rounded-xl hover:bg-gold-400 transition-colors">
<i data-lucide="user-plus" class="w-4 h-4"></i>
</a>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div class="text-center py-16">
<div class="w-24 h-24 bg-gray-100 dark:bg-gray-800 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="calendar" class="w-12 h-12 text-gray-400"></i>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Events Scheduled</h3>
<p class="text-gray-600 dark:text-gray-300 mb-8">
Check back soon for upcoming events and gatherings
</p>
<a href="/contact" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors">
<i data-lucide="mail" class="w-5 h-5"></i>
<span>Contact Us</span>
</a>
</div>
)}
</div>
</section>
<!-- Call to Action -->
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">Stay Connected</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-8">
Don't miss our events and gatherings. Join our church family!
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors">
<i data-lucide="mail" class="w-5 h-5"></i>
<span>Contact Us</span>
</a>
<a href="/about" class="inline-flex items-center space-x-2 border-2 border-primary-600 text-primary-600 dark:text-primary-400 px-8 py-4 rounded-2xl font-semibold hover:bg-primary-600 hover:text-white transition-colors">
<i data-lucide="info" class="w-5 h-5"></i>
<span>Learn More</span>
</a>
</div>
</div>
</section>
</MainLayout>
<style>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,226 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { getChurchName, fetchEventsJson } from '../../lib/bindings.js';
export async function getStaticPaths() {
try {
const eventsJson = fetchEventsJson();
const parsedEvents = JSON.parse(eventsJson);
const events = Array.isArray(parsedEvents) ? parsedEvents : (parsedEvents.items || []);
return events.map((event) => ({
params: { id: event.id },
props: { event }
}));
} catch (e) {
console.error('Failed to generate event paths:', e);
return [];
}
}
const { event } = Astro.props;
let churchName = 'Church';
try {
churchName = getChurchName();
} catch (e) {
console.error('Failed to get church name:', e);
}
// Format event date and time for display
const eventDate = event.formatted_date || 'Date TBD';
const eventTime = event.formatted_time || '';
---
<MainLayout title={`${event.title} - ${churchName}`} description={event.description || `Join us for ${event.title}`}>
<!-- Event Hero -->
<section class="py-16 bg-heavenly-gradient text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- Event Info -->
<div>
<nav class="mb-6">
<a href="/events" class="inline-flex items-center space-x-2 text-blue-200 hover:text-white transition-colors">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
<span>Back to Events</span>
</a>
</nav>
<h1 class="text-4xl lg:text-5xl font-bold mb-6">{event.title}</h1>
<div class="space-y-4 mb-8">
{eventDate && (
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-gold-500 rounded-xl flex items-center justify-center">
<i data-lucide="calendar" class="w-6 h-6 text-black"></i>
</div>
<div>
<p class="font-semibold text-lg">{eventDate}</p>
{eventTime && <p class="text-blue-200">{eventTime}</p>}
</div>
</div>
)}
{event.location && (
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="map-pin" class="w-6 h-6 text-white"></i>
</div>
<div>
<p class="font-semibold">{event.location}</p>
</div>
</div>
)}
{event.organizer && (
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="user" class="w-6 h-6 text-white"></i>
</div>
<div>
<p class="font-semibold">{event.organizer}</p>
</div>
</div>
)}
{event.contact_info && (
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<i data-lucide="phone" class="w-6 h-6 text-white"></i>
</div>
<div>
<p class="font-semibold">{event.contact_info}</p>
</div>
</div>
)}
</div>
{event.registration_url && (
<div class="flex flex-col sm:flex-row gap-4">
<a href={event.registration_url} target="_blank" rel="noopener" class="inline-flex items-center justify-center space-x-2 bg-gold-500 hover:bg-gold-600 text-black px-8 py-4 rounded-2xl font-semibold transition-colors shadow-lg hover:shadow-xl hover:scale-105 transform duration-300">
<i data-lucide="user-plus" class="w-5 h-5"></i>
<span>Register Now</span>
</a>
</div>
)}
</div>
<!-- Event Image -->
{event.image && (
<div class="order-first lg:order-last">
<div class="rounded-3xl overflow-hidden shadow-2xl">
<img src={event.image} alt={event.title} class="w-full h-auto" />
</div>
</div>
)}
</div>
</div>
</section>
<!-- Event Details -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{event.description && (
<div class="mb-12">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">About This Event</h2>
<div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-300 leading-relaxed">{event.description}</p>
</div>
</div>
)}
<!-- Event Details Grid -->
<div class="grid md:grid-cols-2 gap-8 mb-12">
<!-- Quick Details -->
<div class="bg-gradient-to-br from-gray-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Event Details</h3>
<div class="space-y-3">
{eventDate && (
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-300">Date:</span>
<span class="font-medium text-gray-900 dark:text-white">{eventDate}</span>
</div>
)}
{eventTime && (
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-300">Time:</span>
<span class="font-medium text-gray-900 dark:text-white">{eventTime}</span>
</div>
)}
{event.location && (
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-300">Location:</span>
<span class="font-medium text-gray-900 dark:text-white">{event.location}</span>
</div>
)}
{event.organizer && (
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-300">Organizer:</span>
<span class="font-medium text-gray-900 dark:text-white">{event.organizer}</span>
</div>
)}
</div>
</div>
<!-- Contact Information -->
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Need More Info?</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4">
Have questions about this event? We're here to help!
</p>
{event.contact_info ? (
<div class="space-y-2">
<p class="text-sm font-medium text-gray-900 dark:text-white">Contact:</p>
<p class="text-primary-600 dark:text-primary-400">{event.contact_info}</p>
</div>
) : (
<div class="space-y-3">
<div>
<a href="/contact" class="inline-flex items-center space-x-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors">
<i data-lucide="mail" class="w-4 h-4"></i>
<span>Contact Us</span>
</a>
</div>
<div>
<a href="tel:+1234567890" class="inline-flex items-center space-x-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors">
<i data-lucide="phone" class="w-4 h-4"></i>
<span>Call the Church</span>
</a>
</div>
</div>
)}
</div>
</div>
<!-- Call to Action -->
<div class="text-center bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Join Us!</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 max-w-2xl mx-auto">
We'd love to have you join us for this event. Come as you are and experience the warmth of our community.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{event.registration_url ? (
<a href={event.registration_url} target="_blank" rel="noopener" class="inline-flex items-center space-x-2 bg-primary-600 hover:bg-primary-700 text-white px-8 py-4 rounded-2xl font-semibold transition-colors shadow-lg hover:shadow-xl hover:scale-105 transform duration-300">
<i data-lucide="user-plus" class="w-5 h-5"></i>
<span>Register for Event</span>
</a>
) : (
<a href="/contact" class="inline-flex items-center space-x-2 bg-primary-600 hover:bg-primary-700 text-white px-8 py-4 rounded-2xl font-semibold transition-colors shadow-lg hover:shadow-xl hover:scale-105 transform duration-300">
<i data-lucide="mail" class="w-5 h-5"></i>
<span>Get More Info</span>
</a>
)}
<a href="/events" class="inline-flex items-center space-x-2 bg-white/20 hover:bg-white/30 text-primary-600 dark:text-primary-400 px-8 py-4 rounded-2xl font-semibold transition-colors border border-primary-200 dark:border-gray-600">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span>View All Events</span>
</a>
</div>
</div>
</div>
</section>
</MainLayout>

View file

@ -0,0 +1,857 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { getChurchName } from '../../lib/bindings.js';
let churchName = 'Church';
try {
churchName = getChurchName();
} catch (e) {
console.error('Failed to load church name:', e);
}
---
<MainLayout title={`Submit Event - ${churchName}`} description="Submit your event for approval and inclusion in our church bulletin and website">
<!-- Navigation Breadcrumb -->
<section class="py-4 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<nav class="flex items-center space-x-2 text-sm">
<a href="/" class="text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400">Home</a>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<a href="/events" class="text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400">Events</a>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<span class="text-primary-600 dark:text-primary-400 font-medium">Submit Event</span>
</nav>
</div>
</section>
<!-- Hero Section -->
<section class="py-16 bg-heavenly-gradient text-white relative overflow-hidden">
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width=\"60\" height=\"60\" viewBox=\"0 0 60 60\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"none\" fill-rule=\"evenodd\"%3E%3Cg fill=\"%23ffffff\" fill-opacity=\"0.05\"%3E%3Cpath d=\"M30 30c0 16.569-13.431 30-30 30s-30-13.431-30-30 13.431-30 30-30 30 13.431 30 30z\"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-30"></div>
<div class="relative z-10 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="w-20 h-20 bg-gradient-to-br from-gold-400 to-gold-600 rounded-2xl flex items-center justify-center mx-auto mb-8 shadow-2xl">
<i data-lucide="plus" class="w-10 h-10 text-black"></i>
</div>
<h1 class="text-4xl lg:text-6xl font-bold mb-6">
Submit Church Event
</h1>
<p class="text-xl text-blue-100 max-w-3xl mx-auto leading-relaxed mb-8">
Share your ministry event with our church community. All submissions are reviewed for approval before being added to our bulletin and website.
</p>
<!-- Deadline Notice -->
<div class="bg-white/20 backdrop-blur-sm rounded-2xl p-6 max-w-2xl mx-auto border border-white/30">
<div class="flex items-center justify-center space-x-3 mb-3">
<i data-lucide="clock" class="w-6 h-6 text-gold-400"></i>
<h3 class="text-lg font-semibold">Submission Deadline</h3>
</div>
<p class="text-blue-100">
Events must be submitted by <span class="text-gold-300 font-semibold">Thursday 7:00 PM</span> to appear in this week's bulletin.
</p>
</div>
</div>
</section>
<!-- Form Section -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Success Message -->
<div id="successMessage" class="hidden mb-8 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border border-green-200 dark:border-green-700 rounded-2xl p-6">
<div class="flex items-center space-x-3 mb-3">
<div class="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
<i data-lucide="check" class="w-6 h-6 text-white"></i>
</div>
<h3 class="text-xl font-bold text-green-800 dark:text-green-200">Event Submitted Successfully!</h3>
</div>
<p class="text-green-700 dark:text-green-300">
Your event has been submitted for approval and will be reviewed shortly. You'll receive confirmation once it's approved.
</p>
</div>
<!-- Error Message -->
<div id="errorMessage" class="hidden mb-8 bg-gradient-to-r from-red-50 to-pink-50 dark:from-red-900/20 dark:to-pink-900/20 border border-red-200 dark:border-red-700 rounded-2xl p-6">
<div class="flex items-center space-x-3 mb-3">
<div class="w-10 h-10 bg-red-500 rounded-full flex items-center justify-center">
<i data-lucide="x" class="w-6 h-6 text-white"></i>
</div>
<h3 class="text-xl font-bold text-red-800 dark:text-red-200">Submission Error</h3>
</div>
<p id="errorText" class="text-red-700 dark:text-red-300">
There was an error submitting your event. Please try again.
</p>
</div>
<!-- Loading Spinner -->
<div id="loadingSpinner" class="hidden text-center py-12">
<div class="inline-flex items-center space-x-3">
<div class="w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin"></div>
<span class="text-lg text-gray-600 dark:text-gray-300">Submitting your event...</span>
</div>
</div>
<!-- Event Form -->
<form id="eventForm" class="space-y-8">
<!-- Personal Information -->
<div class="bg-gradient-to-br from-gray-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6 flex items-center space-x-3">
<i data-lucide="user" class="w-6 h-6 text-primary-600"></i>
<span>Contact Information</span>
</h2>
<div class="space-y-6">
<div>
<label for="submitter_email" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Your Email Address
</label>
<input
type="email"
id="submitter_email"
name="submitter_email"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
placeholder="your.email@example.com"
/>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">Optional: We'll email you when your event is approved</p>
</div>
</div>
</div>
<!-- Event Details -->
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6 flex items-center space-x-3">
<i data-lucide="calendar" class="w-6 h-6 text-primary-600"></i>
<span>Event Details</span>
</h2>
<div class="space-y-6">
<div>
<label for="title" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Event Title <span class="text-red-500">*</span>
</label>
<input
type="text"
id="title"
name="title"
required
maxlength="100"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
placeholder="e.g., Prayer Meeting, Community Outreach, Youth Bible Study"
/>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">Keep it clear and descriptive</p>
</div>
<div>
<label for="description" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Event Description <span class="text-red-500">*</span>
</label>
<textarea
id="description"
name="description"
required
maxlength="500"
rows="4"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-vertical"
placeholder="Provide details about your event. What will happen? Who should attend? Any special instructions?"
></textarea>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">Maximum 500 characters</p>
</div>
<div>
<label for="category" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Event Category <span class="text-red-500">*</span>
</label>
<select
id="category"
name="category"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
>
<option value="">Select a category</option>
<option value="Service">Service</option>
<option value="Social">Social</option>
<option value="Ministry">Ministry</option>
<option value="Other">Other</option>
</select>
<div class="mt-3 bg-white/60 dark:bg-gray-800/60 rounded-lg p-3 text-sm text-gray-600 dark:text-gray-400">
<p><strong>Service:</strong> Worship services, prayer meetings</p>
<p><strong>Social:</strong> Fellowship meals, game nights</p>
<p><strong>Ministry:</strong> Outreach, Bible studies</p>
<p><strong>Other:</strong> Everything else</p>
</div>
</div>
</div>
</div>
<!-- Date & Time -->
<div class="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6 flex items-center space-x-3">
<i data-lucide="clock" class="w-6 h-6 text-purple-600"></i>
<span>Date & Time</span>
</h2>
<div class="grid md:grid-cols-2 gap-6">
<div>
<label for="start_time" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Start Date & Time <span class="text-red-500">*</span>
</label>
<input
type="datetime-local"
id="start_time"
name="start_time"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
/>
</div>
<div>
<label for="end_time" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
End Date & Time <span class="text-red-500">*</span>
</label>
<input
type="datetime-local"
id="end_time"
name="end_time"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
/>
</div>
</div>
</div>
<!-- Location -->
<div class="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6 flex items-center space-x-3">
<i data-lucide="map-pin" class="w-6 h-6 text-green-600"></i>
<span>Location</span>
</h2>
<div class="space-y-6">
<div>
<label for="location" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Location <span class="text-red-500">*</span>
</label>
<input
type="text"
id="location"
name="location"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
placeholder="e.g., Church Sanctuary, Fellowship Hall, Zoom Meeting, YouTube Live"
/>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">Where will this event take place?</p>
</div>
<div>
<label for="location_url" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Location Link
</label>
<input
type="url"
id="location_url"
name="location_url"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
placeholder="https://maps.google.com/... or https://zoom.us/... or meeting link"
/>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">For physical locations: Google Maps link. For online events: Meeting link, YouTube URL, etc.</p>
</div>
</div>
</div>
<!-- Images -->
<div class="bg-gradient-to-br from-violet-50 to-purple-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6 flex items-center space-x-3">
<i data-lucide="image" class="w-6 h-6 text-violet-600"></i>
<span>Event Images</span>
</h2>
<div class="space-y-6">
<div>
<label for="image" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Event Image
</label>
<div class="relative">
<input
type="file"
id="image"
name="image"
accept="image/*"
class="hidden"
/>
<label
for="image"
class="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl cursor-pointer bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i data-lucide="upload-cloud" class="w-8 h-8 text-gray-400 mb-2"></i>
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">
<span class="font-semibold">Click to upload</span> an event image
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">PNG, JPG, WebP, GIF up to 10MB</p>
</div>
</label>
</div>
<div id="imagePreview" class="mt-4 hidden">
<div class="relative inline-block">
<img id="imagePreviewImg" src="" alt="Preview" class="w-48 h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600" />
<button
type="button"
id="removeImage"
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
>
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<p id="imageInfo" class="text-sm text-gray-500 dark:text-gray-400 mt-2"></p>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">Optional: Add a photo to make your event stand out</p>
</div>
<div>
<label for="thumbnail" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Thumbnail Image
</label>
<div class="relative">
<input
type="file"
id="thumbnail"
name="thumbnail"
accept="image/*"
class="hidden"
/>
<label
for="thumbnail"
class="flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl cursor-pointer bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div class="flex flex-col items-center justify-center py-2">
<i data-lucide="image" class="w-6 h-6 text-gray-400 mb-1"></i>
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">
<span class="font-semibold">Click to upload</span> thumbnail
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">Smaller image for listings</p>
</div>
</label>
</div>
<div id="thumbnailPreview" class="mt-4 hidden">
<div class="relative inline-block">
<img id="thumbnailPreviewImg" src="" alt="Thumbnail Preview" class="w-32 h-20 object-cover rounded-lg border border-gray-200 dark:border-gray-600" />
<button
type="button"
id="removeThumbnail"
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
>
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<p id="thumbnailInfo" class="text-sm text-gray-500 dark:text-gray-400 mt-2"></p>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">Optional: Smaller image for event listings</p>
</div>
</div>
</div>
<!-- Additional Options -->
<div class="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6 flex items-center space-x-3">
<i data-lucide="settings" class="w-6 h-6 text-amber-600"></i>
<span>Additional Options</span>
</h2>
<div>
<label for="recurring_type" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
How Often Does This Event Happen?
</label>
<select
id="recurring_type"
name="recurring_type"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
>
<option value="">One-time event</option>
<!-- Options will be loaded dynamically from API -->
</select>
</div>
</div>
<!-- Submit Button -->
<div class="text-center pt-8">
<button
type="submit"
id="submitBtn"
class="group relative inline-flex items-center justify-center space-x-3 bg-gradient-to-r from-primary-600 via-blue-600 to-purple-600 hover:from-primary-700 hover:via-blue-700 hover:to-purple-700 text-white px-16 py-6 rounded-2xl font-bold text-xl shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none border-2 border-white/20 hover:border-white/40"
>
<!-- Button content -->
<div class="relative flex items-center space-x-3">
<i data-lucide="send" class="w-7 h-7 group-hover:rotate-12 transition-transform"></i>
<span class="tracking-wide">Submit Event for Approval</span>
</div>
</button>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-6 max-w-md mx-auto">
Your event will be reviewed for approval before being published to our bulletin and website
</p>
</div>
</form>
</div>
</section>
</MainLayout>
<script>
// Function to load recurring types from API
async function loadRecurringTypes() {
try {
const response = await fetch('https://api.rockvilletollandsda.church/api/config/recurring-types');
const data = await response.json();
if (Array.isArray(data)) {
const select = document.getElementById('recurring_type') as HTMLSelectElement;
// Clear existing options except the first one (One-time event)
while (select.options.length > 1) {
select.removeChild(select.lastChild!);
}
// Add options from API (skip 'none' as we already have "One-time event")
data.forEach((type: string) => {
if (type !== 'none') {
const option = document.createElement('option');
option.value = type; // Keep original lowercase value
// Convert to readable label
option.textContent = formatRecurringTypeLabel(type);
select.appendChild(option);
}
});
}
} catch (error) {
console.error('Failed to load recurring types:', error);
// Fall back to hardcoded options if API fails
const select = document.getElementById('recurring_type') as HTMLSelectElement;
const fallbackOptions = [
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'biweekly', label: 'Every Two Weeks' },
{ value: 'first_tuesday', label: 'First Tuesday of Month' }
];
fallbackOptions.forEach(type => {
const option = document.createElement('option');
option.value = type.value;
option.textContent = type.label;
select.appendChild(option);
});
}
}
// Function to format recurring type labels
function formatRecurringTypeLabel(type: string): string {
switch (type) {
case 'daily': return 'Daily';
case 'weekly': return 'Weekly';
case 'biweekly': return 'Every Two Weeks';
case 'monthly': return 'Monthly';
case 'first_tuesday': return 'First Tuesday of Month';
case '2nd/3rd Saturday Monthly': return '2nd/3rd Saturday Monthly';
default:
// Capitalize first letter of each word
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
}
}
// Wait for DOM to be fully loaded
document.addEventListener('DOMContentLoaded', () => {
// Load recurring types from API
loadRecurringTypes();
// Auto-set end time when start time changes
const startTimeInput = document.getElementById('start_time') as HTMLInputElement;
const endTimeInput = document.getElementById('end_time') as HTMLInputElement;
startTimeInput?.addEventListener('change', () => {
if (startTimeInput.value && !endTimeInput.value) {
const startDate = new Date(startTimeInput.value);
const endDate = new Date(startDate.getTime() + 60 * 60 * 1000); // Add 1 hour
// Format for datetime-local input
const endString = endDate.toISOString().slice(0, 16);
endTimeInput.value = endString;
}
});
// Image upload handling
function setupImageUpload(inputId: string, previewId: string, imgId: string, infoId: string, removeId: string) {
const input = document.getElementById(inputId) as HTMLInputElement;
const preview = document.getElementById(previewId) as HTMLElement;
const img = document.getElementById(imgId) as HTMLImageElement;
const info = document.getElementById(infoId) as HTMLElement;
const removeBtn = document.getElementById(removeId) as HTMLButtonElement;
console.log('Setting up image upload for:', inputId, { input, preview, img, info, removeBtn });
input?.addEventListener('change', (e) => {
console.log('File input changed:', inputId);
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) {
preview?.classList.add('hidden');
return;
}
console.log('File selected:', file.name, file.size, file.type);
// Validate file size (10MB limit)
if (file.size > 10 * 1024 * 1024) {
alert('File size must be less than 10MB');
input.value = '';
return;
}
// Show preview
const url = URL.createObjectURL(file);
if (img) img.src = url;
if (info) info.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
preview?.classList.remove('hidden');
console.log('Preview updated for:', inputId);
});
removeBtn?.addEventListener('click', () => {
console.log('Remove button clicked for:', inputId);
input.value = '';
preview?.classList.add('hidden');
if (img && img.src) URL.revokeObjectURL(img.src);
});
}
// Setup both image uploads
setupImageUpload('image', 'imagePreview', 'imagePreviewImg', 'imageInfo', 'removeImage');
setupImageUpload('thumbnail', 'thumbnailPreview', 'thumbnailPreviewImg', 'thumbnailInfo', 'removeThumbnail');
// Real-time validation
function setupRealtimeValidation() {
const fields = [
{
id: 'title',
validate: (value: string) => {
if (!value.trim()) return 'Title is required';
if (value.trim().length < 3) return 'Title must be at least 3 characters';
if (value.length > 100) return 'Title must be less than 100 characters';
return null;
}
},
{
id: 'description',
validate: (value: string) => {
if (!value.trim()) return 'Description is required';
if (value.trim().length < 10) return 'Description must be at least 10 characters';
if (value.length > 500) return 'Description must be less than 500 characters';
return null;
}
},
{
id: 'location',
validate: (value: string) => {
if (!value.trim()) return 'Location is required';
if (value.trim().length < 2) return 'Location must be at least 2 characters';
return null;
}
},
{
id: 'category',
validate: (value: string) => {
if (!value) return 'Please select a category';
return null;
}
},
{
id: 'submitter_email',
validate: (value: string) => {
if (value && !value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
return 'Please enter a valid email address';
}
return null;
}
},
{
id: 'location_url',
validate: (value: string) => {
if (value && !value.match(/^https?:\/\/.+/)) {
return 'Please enter a valid URL starting with http:// or https://';
}
return null;
}
}
];
fields.forEach(field => {
const input = document.getElementById(field.id) as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
if (!input) return;
// Create error message element
const errorMsg = document.createElement('div');
errorMsg.id = `${field.id}-error`;
errorMsg.className = 'text-sm text-red-600 dark:text-red-400 mt-1 hidden';
input.parentNode?.appendChild(errorMsg);
// Validation function
const validateField = () => {
const error = field.validate(input.value);
const errorElement = document.getElementById(`${field.id}-error`);
if (error) {
errorElement!.textContent = error;
errorElement!.classList.remove('hidden');
input.classList.add('border-red-500', 'dark:border-red-400');
input.classList.remove('border-gray-300', 'dark:border-gray-600');
} else {
errorElement!.classList.add('hidden');
input.classList.remove('border-red-500', 'dark:border-red-400');
input.classList.add('border-gray-300', 'dark:border-gray-600');
}
};
// Add event listeners
input.addEventListener('blur', validateField);
input.addEventListener('input', () => {
// Only validate on input if field has been touched (has error showing)
if (!document.getElementById(`${field.id}-error`)?.classList.contains('hidden')) {
validateField();
}
});
});
// Date validation
const startTimeInput = document.getElementById('start_time') as HTMLInputElement;
const endTimeInput = document.getElementById('end_time') as HTMLInputElement;
[startTimeInput, endTimeInput].forEach(input => {
if (!input) return;
const errorMsg = document.createElement('div');
errorMsg.id = `${input.id}-error`;
errorMsg.className = 'text-sm text-red-600 dark:text-red-400 mt-1 hidden';
input.parentNode?.appendChild(errorMsg);
});
const validateDates = () => {
const startValue = startTimeInput?.value;
const endValue = endTimeInput?.value;
const startError = document.getElementById('start_time-error');
const endError = document.getElementById('end_time-error');
// Clear previous errors
startError?.classList.add('hidden');
endError?.classList.add('hidden');
startTimeInput?.classList.remove('border-red-500', 'dark:border-red-400');
endTimeInput?.classList.remove('border-red-500', 'dark:border-red-400');
startTimeInput?.classList.add('border-gray-300', 'dark:border-gray-600');
endTimeInput?.classList.add('border-gray-300', 'dark:border-gray-600');
if (!startValue) {
startError!.textContent = 'Start time is required';
startError?.classList.remove('hidden');
startTimeInput?.classList.add('border-red-500', 'dark:border-red-400');
return;
}
if (!endValue) {
endError!.textContent = 'End time is required';
endError?.classList.remove('hidden');
endTimeInput?.classList.add('border-red-500', 'dark:border-red-400');
return;
}
const startDate = new Date(startValue);
const endDate = new Date(endValue);
const now = new Date();
if (startDate < now) {
startError!.textContent = 'Start time cannot be in the past';
startError?.classList.remove('hidden');
startTimeInput?.classList.add('border-red-500', 'dark:border-red-400');
}
if (endDate <= startDate) {
endError!.textContent = 'End time must be after start time';
endError?.classList.remove('hidden');
endTimeInput?.classList.add('border-red-500', 'dark:border-red-400');
}
};
startTimeInput?.addEventListener('blur', validateDates);
endTimeInput?.addEventListener('blur', validateDates);
startTimeInput?.addEventListener('change', validateDates);
endTimeInput?.addEventListener('change', validateDates);
}
setupRealtimeValidation();
// Form submission
const form = document.getElementById('eventForm') as HTMLFormElement;
const submitBtn = document.getElementById('submitBtn') as HTMLButtonElement;
const loadingSpinner = document.getElementById('loadingSpinner') as HTMLElement;
const successMessage = document.getElementById('successMessage') as HTMLElement;
const errorMessage = document.getElementById('errorMessage') as HTMLElement;
const errorText = document.getElementById('errorText') as HTMLElement;
form?.addEventListener('submit', async (e) => {
e.preventDefault();
// Hide previous messages
successMessage.classList.add('hidden');
errorMessage.classList.add('hidden');
// Show loading
form.classList.add('hidden');
loadingSpinner.classList.remove('hidden');
try {
const formData = new FormData(form);
// Convert datetime-local format to ISO string
const startTime = formData.get('start_time') as string;
const endTime = formData.get('end_time') as string;
if (!startTime || !endTime) {
throw new Error('Please fill in all required fields');
}
// Convert to ISO format for the API
const startTimeISO = new Date(startTime).toISOString();
const endTimeISO = new Date(endTime).toISOString();
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const location = formData.get('location') as string;
const category = formData.get('category') as string;
// Detailed validation
if (!title || title.trim().length < 3) {
throw new Error('Event title must be at least 3 characters long');
}
if (!description || description.trim().length < 10) {
throw new Error('Event description must be at least 10 characters long');
}
if (!location || location.trim().length < 2) {
throw new Error('Location must be at least 2 characters long');
}
if (!category) {
throw new Error('Please select an event category');
}
// Validate dates
const startDate = new Date(startTime);
const endDate = new Date(endTime);
const now = new Date();
if (startDate < now) {
throw new Error('Event start time cannot be in the past');
}
if (endDate <= startDate) {
throw new Error('Event end time must be after start time');
}
// Validate email if provided
const email = (formData.get('submitter_email') as string)?.trim();
if (email && !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
throw new Error('Please enter a valid email address');
}
const locationUrl = formData.get('location_url') as string;
const recurringType = formData.get('recurring_type') as string;
// Submit to API endpoint (since we can't use church-core directly in browser)
const formDataToSubmit = new FormData();
formDataToSubmit.append('title', title);
formDataToSubmit.append('description', description);
formDataToSubmit.append('start_time', startTimeISO);
formDataToSubmit.append('end_time', endTimeISO);
formDataToSubmit.append('location', location);
formDataToSubmit.append('category', category);
if (locationUrl) formDataToSubmit.append('location_url', locationUrl);
if (recurringType) formDataToSubmit.append('recurring_type', recurringType);
if (email) formDataToSubmit.append('submitter_email', email);
// Add images if selected
const imageFile = (document.getElementById('image') as HTMLInputElement).files?.[0];
const thumbnailFile = (document.getElementById('thumbnail') as HTMLInputElement).files?.[0];
if (imageFile) formDataToSubmit.append('image', imageFile);
if (thumbnailFile) formDataToSubmit.append('thumbnail', thumbnailFile);
// Submit to the API endpoint (same as the legacy form)
const response = await fetch('https://api.rockvilletollandsda.church/api/events/submit', {
method: 'POST',
body: formDataToSubmit
});
if (response.ok) {
try {
const result = await response.json();
if (result.success) {
loadingSpinner.classList.add('hidden');
successMessage.classList.remove('hidden');
form.reset();
// Clear image previews
document.getElementById('imagePreview')?.classList.add('hidden');
document.getElementById('thumbnailPreview')?.classList.add('hidden');
} else {
throw new Error(result.message || 'Submission failed');
}
} catch (jsonError) {
// If JSON parsing fails but response is OK, assume success
loadingSpinner.classList.add('hidden');
successMessage.classList.remove('hidden');
form.reset();
// Clear image previews
document.getElementById('imagePreview')?.classList.add('hidden');
document.getElementById('thumbnailPreview')?.classList.add('hidden');
}
} else {
const errorText = await response.text();
throw new Error(`Server error: ${response.status} - ${errorText || 'Unknown error'}`);
}
} catch (error) {
console.error('Submission error:', error);
loadingSpinner.classList.add('hidden');
form.classList.remove('hidden');
errorMessage.classList.remove('hidden');
errorText.textContent = error instanceof Error ? error.message : 'An unexpected error occurred';
}
});
}); // Close DOMContentLoaded
</script>
<style>
/* Form styling enhancements */
input:focus, textarea:focus, select:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Animation for form sections */
form > div {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -0,0 +1,419 @@
---
import MainLayout from '../layouts/MainLayout.astro';
import { SERVICE_TIMES } from '../lib/constants.js';
import {
getChurchName,
getMissionStatement,
fetchFeaturedEventsJson,
fetchSermonsJson,
fetchRandomBibleVerseJson,
fetchBibleVerseJson,
getStreamLiveStatus
} from '../lib/bindings.js';
let churchName = 'Welcome to Our Church';
let missionStatement = 'Proclaiming the Three Angels\' Message with love and truth';
let featuredEvents = [];
let latestSermons = [];
let dailyVerse = null;
let isLiveStreamActive = false;
let threeAngelsVerses = {
first: null,
second: null,
third: null
};
try {
churchName = getChurchName();
missionStatement = getMissionStatement();
// Get featured events
const eventsJson = fetchFeaturedEventsJson();
const parsedEvents = JSON.parse(eventsJson);
featuredEvents = (Array.isArray(parsedEvents) ? parsedEvents : (parsedEvents.items || [])).slice(0, 3);
// Get latest sermons
const sermonsJson = fetchSermonsJson();
const parsedSermons = JSON.parse(sermonsJson);
latestSermons = (Array.isArray(parsedSermons) ? parsedSermons : (parsedSermons.items || [])).slice(0, 3);
// Get daily verse
const verseJson = fetchRandomBibleVerseJson();
dailyVerse = JSON.parse(verseJson);
// Check live stream status
isLiveStreamActive = getStreamLiveStatus();
// Get Three Angels verses
const firstAngelJson = fetchBibleVerseJson('Revelation 14:7');
threeAngelsVerses.first = JSON.parse(firstAngelJson)[0];
const secondAngelJson = fetchBibleVerseJson('Revelation 14:8');
threeAngelsVerses.second = JSON.parse(secondAngelJson)[0];
const thirdAngelJson = fetchBibleVerseJson('Revelation 14:9-10');
threeAngelsVerses.third = JSON.parse(thirdAngelJson)[0];
} catch (e) {
console.error('Failed to load home page data:', e);
featuredEvents = [];
latestSermons = [];
dailyVerse = null;
}
---
<MainLayout title={churchName} description={missionStatement}>
<!-- Hero Section with Three Angels' Message -->
<section class="relative min-h-screen flex items-center justify-center overflow-hidden bg-heavenly-gradient">
<!-- Animated Background Elements -->
<div class="absolute inset-0">
<div class="absolute top-20 left-10 w-32 h-32 bg-white/10 rounded-full blur-xl animate-float"></div>
<div class="absolute top-40 right-20 w-24 h-24 bg-gold-400/20 rounded-full blur-lg animate-float" style="animation-delay: 1s;"></div>
<div class="absolute bottom-32 left-1/4 w-40 h-40 bg-primary-300/10 rounded-full blur-2xl animate-float" style="animation-delay: 2s;"></div>
</div>
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-white">
<div class="mb-8" data-animate>
<div class="inline-flex items-center space-x-2 bg-white/20 backdrop-blur-sm rounded-full px-6 py-3 mb-6">
<i data-lucide="sparkles" class="w-5 h-5 text-gold-400"></i>
<span class="text-sm font-medium">Seventh-day Adventist Church</span>
</div>
<h1 class="text-5xl lg:text-7xl font-bold mb-6 leading-tight">
<span class="block text-white">{churchName}</span>
</h1>
<p class="text-xl lg:text-2xl text-blue-100 mb-8 max-w-3xl mx-auto leading-relaxed">
{missionStatement}
</p>
<!-- Three Angels Highlight -->
<div class="flex justify-center items-center space-x-4 mb-12">
<div class="flex items-center space-x-2 bg-white/15 backdrop-blur-sm rounded-xl px-4 py-2">
<span class="w-6 h-6 bg-gold-500 rounded-full flex items-center justify-center text-xs font-bold text-black">1</span>
<span class="w-6 h-6 bg-gold-500 rounded-full flex items-center justify-center text-xs font-bold text-black">2</span>
<span class="w-6 h-6 bg-gold-500 rounded-full flex items-center justify-center text-xs font-bold text-black">3</span>
<span class="text-sm font-medium ml-2">Three Angels' Message</span>
</div>
</div>
</div>
<!-- CTA Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-12" data-animate>
<a href="/live" class="inline-flex items-center space-x-2 bg-sacred-gradient text-white px-8 py-4 rounded-2xl font-semibold shadow-2xl hover:shadow-divine transition-all duration-300 hover:scale-105">
<i data-lucide="video" class="w-5 h-5"></i>
<span>Watch Live</span>
</a>
<a href="/events" class="inline-flex items-center space-x-2 bg-white/20 backdrop-blur-sm text-white px-8 py-4 rounded-2xl font-semibold border border-white/30 hover:bg-white/30 transition-all duration-300">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span>View Events</span>
</a>
</div>
<!-- Service Times -->
<div class="flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-8 text-blue-100" data-animate>
<div class="flex items-center space-x-2">
<i data-lucide="sun" class="w-5 h-5 text-gold-400"></i>
<span>Sabbath School: {SERVICE_TIMES.SABBATH_SCHOOL}</span>
</div>
<div class="flex items-center space-x-2">
<i data-lucide="church" class="w-5 h-5 text-gold-400"></i>
<span>Divine Service: {SERVICE_TIMES.DIVINE_SERVICE}</span>
</div>
</div>
</div>
<!-- Live Status Banner -->
<div id="live-status-banner" class="absolute top-8 right-8 z-20" style={isLiveStreamActive ? '' : 'display: none;'}>
<div class="bg-red-500 text-white px-4 py-2 rounded-full flex items-center space-x-2 shadow-lg animate-pulse">
<div class="w-3 h-3 bg-white rounded-full"></div>
<span class="font-semibold text-sm">LIVE NOW</span>
</div>
</div>
</section>
<!-- Three Angels' Message Section -->
<section class="py-24 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16" data-animate>
<h2 class="text-4xl lg:text-5xl font-bold text-divine-gradient mb-6">The Three Angels' Message</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
From Revelation 14, these three angels proclaim God's final message to the world before Jesus returns.
</p>
</div>
<div class="grid md:grid-cols-3 gap-8 lg:gap-12">
<!-- First Angel -->
<div class="group bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 hover:shadow-large transition-all duration-500 hover:-translate-y-2" data-animate>
<div class="w-20 h-20 bg-gradient-to-br from-primary-500 to-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform">
<span class="text-3xl font-bold text-white">1</span>
</div>
<h3 class="text-2xl font-bold text-center mb-4 text-primary-700 dark:text-primary-300">Fear God & Give Glory</h3>
<p class="text-gray-600 dark:text-gray-300 text-center leading-relaxed">
{threeAngelsVerses.first ? `"${threeAngelsVerses.first.text}"` : '"Fear God and give glory to Him, for the hour of His judgment has come; and worship Him who made heaven and earth, the sea and springs of water."'}
</p>
<p class="text-sm text-primary-600 dark:text-primary-400 text-center mt-4 font-medium">{threeAngelsVerses.first?.reference || 'Revelation 14:7'}</p>
</div>
<!-- Second Angel -->
<div class="group bg-gradient-to-br from-purple-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 hover:shadow-large transition-all duration-500 hover:-translate-y-2" data-animate>
<div class="w-20 h-20 bg-gradient-to-br from-purple-500 to-pink-500 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform">
<span class="text-3xl font-bold text-white">2</span>
</div>
<h3 class="text-2xl font-bold text-center mb-4 text-purple-700 dark:text-purple-300">Babylon is Fallen</h3>
<p class="text-gray-600 dark:text-gray-300 text-center leading-relaxed">
{threeAngelsVerses.second ? `"${threeAngelsVerses.second.text}"` : '"Babylon is fallen, is fallen, that great city, because she has made all nations drink of the wine of the wrath of her fornication."'}
</p>
<p class="text-sm text-purple-600 dark:text-purple-400 text-center mt-4 font-medium">{threeAngelsVerses.second?.reference || 'Revelation 14:8'}</p>
</div>
<!-- Third Angel -->
<div class="group bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 hover:shadow-large transition-all duration-500 hover:-translate-y-2" data-animate>
<div class="w-20 h-20 bg-gradient-to-br from-amber-500 to-orange-500 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform">
<span class="text-3xl font-bold text-white">3</span>
</div>
<h3 class="text-2xl font-bold text-center mb-4 text-amber-700 dark:text-amber-300">Avoid the Mark</h3>
<p class="text-gray-600 dark:text-gray-300 text-center leading-relaxed">
{threeAngelsVerses.third ? `"${threeAngelsVerses.third.text}"` : '"If anyone worships the beast and his image, and receives his mark on his forehead or on his hand, he himself shall also drink of the wine of the wrath of God."'}
</p>
<p class="text-sm text-amber-600 dark:text-amber-400 text-center mt-4 font-medium">{threeAngelsVerses.third?.reference || 'Revelation 14:9-10'}</p>
</div>
</div>
</div>
</section>
<!-- Daily Verse Section -->
{dailyVerse && (
<section class="py-16 bg-gradient-to-r from-primary-600 via-purple-600 to-primary-700 text-white relative overflow-hidden">
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.1"%3E%3Ccircle cx="30" cy="30" r="1"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-50"></div>
<div class="relative z-10 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center" data-animate>
<div class="flex items-center justify-center space-x-3 mb-6">
<div class="w-12 h-12 bg-gold-500 rounded-full flex items-center justify-center">
<i data-lucide="book-open" class="w-6 h-6 text-black"></i>
</div>
<h2 class="text-2xl lg:text-3xl font-bold">Today's Scripture</h2>
</div>
<blockquote class="text-xl lg:text-2xl font-medium italic leading-relaxed mb-6">
"{dailyVerse.text}"
</blockquote>
<p class="text-gold-300 font-semibold">— {dailyVerse.reference}</p>
</div>
</section>
)}
<!-- Featured Events Section -->
{featuredEvents.length > 0 && (
<section class="py-24 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16" data-animate>
<h2 class="text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-6">Upcoming Events</h2>
<p class="text-xl text-gray-600 dark:text-gray-300">Join us for fellowship, worship, and spiritual growth</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{featuredEvents.map(event => (
<div class="bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-medium hover:shadow-large transition-all duration-300 hover:-translate-y-1 group" data-animate>
{event.image && (
<div class="aspect-video overflow-hidden">
<img src={event.image} alt={event.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
</div>
)}
<div class="p-6">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{event.title}</h3>
<div class="flex items-center space-x-2 text-primary-600 dark:text-primary-400 mb-3">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span class="text-sm font-medium">{event.formatted_date}</span>
</div>
{event.description && (
<p class="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">{event.description}</p>
)}
<a href={`/events/${event.id}`} class="inline-flex items-center space-x-2 text-primary-600 dark:text-primary-400 font-medium hover:text-primary-700 dark:hover:text-primary-300 transition-colors">
<span>Learn More</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
</div>
))}
</div>
<div class="text-center mt-12" data-animate>
<a href="/events" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors shadow-lg hover:shadow-xl">
<span>View All Events</span>
<i data-lucide="arrow-right" class="w-5 h-5"></i>
</a>
</div>
</div>
</section>
)}
<!-- Latest Sermons Section -->
{latestSermons.length > 0 && (
<section class="py-24 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16" data-animate>
<h2 class="text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-6">Recent Sermons</h2>
<p class="text-xl text-gray-600 dark:text-gray-300">Be inspired by God's Word through our recent messages</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{latestSermons.map(sermon => (
<div class="bg-gradient-to-br from-gray-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-6 hover:shadow-large transition-all duration-300 hover:-translate-y-1 group" data-animate>
<div class="w-16 h-16 bg-gradient-to-br from-primary-500 to-primary-600 rounded-2xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<i data-lucide="play" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{sermon.title}</h3>
<div class="space-y-2 mb-4">
<div class="flex items-center space-x-2 text-gray-600 dark:text-gray-300">
<i data-lucide="user" class="w-4 h-4"></i>
<span class="text-sm">{sermon.speaker}</span>
</div>
<div class="flex items-center space-x-2 text-gray-600 dark:text-gray-300">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span class="text-sm">{sermon.date || 'Recent'}</span>
</div>
</div>
{sermon.description && (
<p class="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed text-sm">{sermon.description}</p>
)}
<a href={sermon.videoUrl || `/sermons/${sermon.id}`} target={sermon.videoUrl ? "_blank" : "_self"} class="inline-flex items-center space-x-2 bg-primary-600 text-white px-4 py-2 rounded-xl font-medium hover:bg-primary-700 transition-colors">
<i data-lucide="play" class="w-4 h-4"></i>
<span>{sermon.videoUrl ? 'Watch Now' : 'View Details'}</span>
</a>
</div>
))}
</div>
<div class="text-center mt-12" data-animate>
<a href="/sermons" class="inline-flex items-center space-x-2 border-2 border-primary-600 text-primary-600 dark:text-primary-400 px-8 py-4 rounded-2xl font-semibold hover:bg-primary-600 hover:text-white dark:hover:text-white transition-colors">
<span>View All Sermons</span>
<i data-lucide="arrow-right" class="w-5 h-5"></i>
</a>
</div>
</div>
</section>
)}
<!-- Mobile App Download Section -->
<section class="py-24 bg-gradient-to-br from-gray-50 via-blue-50 to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16" data-animate>
<h2 class="text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-6">Faith in Your Pocket</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
Access sermons, events, and stay connected with our church family through our mobile app designed for spiritual growth.
</p>
</div>
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- App Features -->
<div class="space-y-8" data-animate>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg">
<div class="flex items-center space-x-4 mb-4">
<div class="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<i data-lucide="video" class="w-6 h-6 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Live Streaming</h3>
</div>
<p class="text-gray-600 dark:text-gray-300">Watch our Sabbath services and special events live from anywhere</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg">
<div class="flex items-center space-x-4 mb-4">
<div class="w-12 h-12 bg-gradient-to-br from-gold-500 to-amber-500 rounded-xl flex items-center justify-center">
<i data-lucide="book-open" class="w-6 h-6 text-black"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Sermons & Studies</h3>
</div>
<p class="text-gray-600 dark:text-gray-300">Access our complete library of sermons and Bible study materials</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg">
<div class="flex items-center space-x-4 mb-4">
<div class="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
<i data-lucide="calendar" class="w-6 h-6 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Events & Bulletins</h3>
</div>
<p class="text-gray-600 dark:text-gray-300">Stay updated with church events and read the latest bulletins</p>
</div>
</div>
<!-- Download Buttons -->
<div class="text-center lg:text-left" data-animate>
<div class="space-y-4 max-w-sm mx-auto lg:mx-0">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Download Our Mobile App</h3>
<p class="text-gray-600 dark:text-gray-300 mb-8">
Stay connected with sermons, events, and church activities wherever you go.
Our app makes it easy to access spiritual content and stay engaged with our community.
</p>
<div class="flex flex-wrap gap-4 justify-center lg:justify-start items-center">
<!-- iOS App Store -->
<a href="https://apps.apple.com/us/app/rtsda/id6738595657" target="_blank" rel="noopener"
class="block transition-transform duration-300 hover:scale-105">
<img src="/images/app-store-badge.svg" alt="Download on the App Store" class="h-16">
</a>
<!-- Android APK Download -->
<button onclick="downloadApk()"
class="flex items-center gap-4 bg-primary-600 hover:bg-primary-700 text-white p-4 rounded-2xl transition-all duration-300 hover:scale-105 hover:shadow-xl">
<svg viewBox="0 0 24 24" class="h-10 w-10 fill-gold-400">
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85a.637.637 0 0 0-.83.22l-1.88 3.24a11.463 11.463 0 0 0-8.94 0L5.65 5.67a.643.643 0 0 0-.87-.2c-.28.18-.37.54-.22.83L6.4 9.48A10.78 10.78 0 0 0 1 18h22a10.78 10.78 0 0 0-5.4-8.52zM7 15.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5zm10 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5z"/>
</svg>
<div class="text-left">
<div class="text-sm opacity-80">DOWNLOAD APK</div>
<div class="text-lg font-semibold">Android</div>
</div>
</button>
</div>
<div class="mt-6 p-4 bg-white/50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-300">
<i data-lucide="info" class="w-4 h-4 text-primary-500"></i>
<span>Available on both iOS and Android platforms. Download today to access sermons, events, and stay connected with our church community.</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Call to Action Section -->
<section class="py-24 bg-heavenly-gradient text-white relative overflow-hidden">
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.1"%3E%3Ccircle cx="30" cy="30" r="2"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-50"></div>
<div class="relative z-10 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center" data-animate>
<h2 class="text-4xl lg:text-5xl font-bold mb-6">Visit Us This Sabbath</h2>
<p class="text-xl text-blue-100 mb-8 leading-relaxed">
Experience the joy of Sabbath worship and discover the peace that comes from fellowship with God and His people
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" class="inline-flex items-center space-x-2 bg-gold-500 text-black px-8 py-4 rounded-2xl font-semibold hover:bg-gold-400 transition-colors shadow-lg hover:shadow-xl">
<i data-lucide="map-pin" class="w-5 h-5"></i>
<span>Get Directions</span>
</a>
<a href="/about" class="inline-flex items-center space-x-2 bg-white/20 backdrop-blur-sm text-white px-8 py-4 rounded-2xl font-semibold border border-white/30 hover:bg-white/30 transition-colors">
<i data-lucide="info" class="w-5 h-5"></i>
<span>Learn More</span>
</a>
</div>
</div>
</section>
</MainLayout>
<script src="/live-status-updater.js" is:inline></script>
<script>
// Initialize home page live banner updates
document.addEventListener('DOMContentLoaded', () => {
window.liveStatusUpdater.initHomePage();
});
// Function to download Android APK - make it globally available
window.downloadApk = function() {
window.location.href = 'https://api.rockvilletollandsda.church/uploads/rtsda_android/current';
};
</script>

View file

@ -0,0 +1,158 @@
---
import MainLayout from '../layouts/MainLayout.astro';
import { getChurchName, getStreamLiveStatus, getLivestreamUrl } from '../lib/bindings.js';
let churchName = 'Church';
let isLive = false;
let streamUrl = '';
try {
churchName = getChurchName();
isLive = getStreamLiveStatus();
streamUrl = getLivestreamUrl();
} catch (e) {
console.error('Failed to get live stream data:', e);
}
---
<MainLayout title={`Live Stream - ${churchName}`} description="Watch our live Sabbath services and special events">
<!-- Live Stream Hero -->
<section class="py-12 bg-heavenly-gradient text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div id="live-status-hero" class="flex items-center justify-center space-x-3 mb-6">
{isLive ? (
<div class="flex items-center space-x-2 bg-red-500 rounded-full px-4 py-2">
<div class="w-3 h-3 bg-white rounded-full animate-ping"></div>
<span class="font-semibold">LIVE NOW</span>
</div>
) : (
<div class="flex items-center space-x-2 bg-white/20 rounded-full px-4 py-2">
<div class="w-3 h-3 bg-gray-300 rounded-full"></div>
<span class="font-semibold">OFFLINE</span>
</div>
)}
</div>
<h1 class="text-4xl lg:text-6xl font-bold mb-6">Live Stream</h1>
<p class="text-xl text-blue-100 max-w-2xl mx-auto">
Join us for worship, fellowship, and the study of God's Word
</p>
</div>
</section>
<!-- Video Player Section -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Video Container -->
<div class="bg-black rounded-2xl overflow-hidden shadow-2xl mb-12">
<div class="aspect-video">
{isLive ? (
<!-- Owncast HLS live stream -->
<video
id="live-video-player"
src={streamUrl}
class="w-full h-full"
controls
muted
playsinline
poster=""
preload="none">
<p class="text-white text-center">Your browser does not support the video tag or HLS streaming.</p>
</video>
) : (
<div class="w-full h-full flex items-center justify-center bg-gray-900 text-white">
<div class="text-center">
<i data-lucide="video-off" class="w-16 h-16 mx-auto mb-4 text-gray-400"></i>
<h3 class="text-2xl font-semibold mb-2">Stream is Offline</h3>
<p class="text-gray-400">We'll be back for our next service</p>
</div>
</div>
)}
</div>
</div>
<!-- Service Schedule -->
<div class="grid md:grid-cols-2 gap-8">
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Sabbath Services</h3>
<div class="space-y-4">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-primary-500 rounded-xl flex items-center justify-center">
<i data-lucide="sun" class="w-6 h-6 text-white"></i>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white">Sabbath School</h4>
<p class="text-gray-600 dark:text-gray-300">Saturdays at 9:30 AM</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-gold-500 rounded-xl flex items-center justify-center">
<i data-lucide="church" class="w-6 h-6 text-black"></i>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white">Divine Service</h4>
<p class="text-gray-600 dark:text-gray-300">Saturdays at 11:00 AM</p>
</div>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Special Events</h3>
<div class="space-y-4">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-purple-500 rounded-xl flex items-center justify-center">
<i data-lucide="calendar" class="w-6 h-6 text-white"></i>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white">Prayer Meeting</h4>
<p class="text-gray-600 dark:text-gray-300">Wednesdays at 7:00 PM</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center">
<i data-lucide="book-open" class="w-6 h-6 text-black"></i>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white">Bible Study</h4>
<p class="text-gray-600 dark:text-gray-300">Check events for schedule</p>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="text-center mt-12">
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/events" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span>View All Events</span>
</a>
<a href="/sermons" class="inline-flex items-center space-x-2 border-2 border-primary-600 text-primary-600 dark:text-primary-400 px-8 py-4 rounded-2xl font-semibold hover:bg-primary-600 hover:text-white transition-colors">
<i data-lucide="play" class="w-5 h-5"></i>
<span>Watch Past Sermons</span>
</a>
</div>
</div>
</div>
</section>
</MainLayout>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest" is:inline></script>
<script src="/live-status-updater.js" is:inline></script>
<script>
// Initialize live page video player updates
document.addEventListener('DOMContentLoaded', () => {
window.liveStatusUpdater.initLivePage();
// Setup HLS.js for the initial video element if stream is live
const initialVideo = document.getElementById('live-video-player');
if (initialVideo && initialVideo.src) {
window.liveStatusUpdater.setupHLS(initialVideo, initialVideo.src);
}
});
</script>

View file

@ -0,0 +1,257 @@
---
import MainLayout from '../layouts/MainLayout.astro';
import { getChurchName, fetchSermonsJson, fetchLivestreamArchiveJson } from '../lib/bindings.js';
let churchName = 'Church';
let sermons = [];
let livestreams = [];
try {
churchName = getChurchName();
// Get regular sermons
const sermonsJson = fetchSermonsJson();
const parsedSermons = JSON.parse(sermonsJson);
sermons = Array.isArray(parsedSermons) ? parsedSermons : (parsedSermons.items || []);
// Get livestream archive
const livestreamsJson = fetchLivestreamArchiveJson();
const parsedLivestreams = JSON.parse(livestreamsJson);
livestreams = Array.isArray(parsedLivestreams) ? parsedLivestreams : (parsedLivestreams.items || []);
} catch (e) {
console.error('Failed to load sermons:', e);
}
// Combine all content for display
const allContent = [...sermons, ...livestreams];
---
<MainLayout title={`Sermons - ${churchName}`} description="Listen to our recent sermons and be inspired by God's Word">
<!-- Sermons Hero -->
<section class="py-16 bg-heavenly-gradient text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl lg:text-6xl font-bold mb-6">Sermons</h1>
<p class="text-xl text-blue-100 max-w-2xl mx-auto">
Be inspired and encouraged through messages from God's Word
</p>
</div>
</section>
<!-- Sermons Grid -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Filter Tabs -->
<div class="mb-12">
<div class="flex flex-wrap justify-center gap-2">
<button class="filter-btn active" data-filter="sermons">
<i data-lucide="mic" class="w-4 h-4 mr-2"></i>
Sermons
</button>
<button class="filter-btn" data-filter="livestream">
<i data-lucide="video" class="w-4 h-4 mr-2"></i>
LiveStream Archive
</button>
<button class="filter-btn" data-filter="recent">
<i data-lucide="clock" class="w-4 h-4 mr-2"></i>
Recent Messages
</button>
</div>
</div>
{allContent.length > 0 ? (
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8" id="sermons-grid">
{allContent.map(item => (
<div
class="sermon-card bg-gradient-to-br from-gray-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6 hover:shadow-lg transition-all duration-300 hover:-translate-y-1 group"
data-animate
data-category={livestreams.includes(item) ? 'livestream' : 'sermon'}
data-speaker={item.speaker}
data-date={item.date}
>
<div class="w-16 h-16 bg-gradient-to-br from-primary-500 to-primary-600 rounded-2xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<i data-lucide="play" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3 line-clamp-2">{item.title}</h3>
<div class="space-y-2 mb-4">
{item.speaker && (
<div class="flex items-center space-x-2 text-gray-600 dark:text-gray-300">
<i data-lucide="user" class="w-4 h-4"></i>
<span class="text-sm">{item.speaker}</span>
</div>
)}
{item.date && (
<div class="flex items-center space-x-2 text-gray-600 dark:text-gray-300">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span class="text-sm">{item.date}</span>
</div>
)}
{item.duration && (
<div class="flex items-center space-x-2 text-gray-600 dark:text-gray-300">
<i data-lucide="clock" class="w-4 h-4"></i>
<span class="text-sm">{item.duration}</span>
</div>
)}
{livestreams.includes(item) && (
<div class="flex items-center space-x-2 text-purple-600 dark:text-purple-400">
<i data-lucide="radio" class="w-4 h-4"></i>
<span class="text-sm font-medium">LiveStream Archive</span>
</div>
)}
</div>
{item.description && (
<p class="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed text-sm line-clamp-3">{item.description}</p>
)}
<div class="flex space-x-2">
{item.videoUrl && (
<a href={item.videoUrl} target="_blank" rel="noopener" class="flex-1 inline-flex items-center justify-center space-x-2 bg-primary-600 text-white px-4 py-2 rounded-xl font-medium hover:bg-primary-700 transition-colors text-sm">
<i data-lucide="play" class="w-4 h-4"></i>
<span>Watch</span>
</a>
)}
{item.audioUrl && (
<a href={item.audioUrl} target="_blank" rel="noopener" class="flex-1 inline-flex items-center justify-center space-x-2 bg-purple-600 text-white px-4 py-2 rounded-xl font-medium hover:bg-purple-700 transition-colors text-sm">
<i data-lucide="headphones" class="w-4 h-4"></i>
<span>Listen</span>
</a>
)}
{item.downloadUrl && (
<a href={item.downloadUrl} download class="inline-flex items-center justify-center p-2 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<i data-lucide="download" class="w-4 h-4"></i>
</a>
)}
</div>
</div>
))}
</div>
) : (
<div class="text-center py-16">
<div class="w-24 h-24 bg-gray-100 dark:bg-gray-800 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="headphones" class="w-12 h-12 text-gray-400"></i>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Sermons Available</h3>
<p class="text-gray-600 dark:text-gray-300 mb-8">
Check back soon for new messages from God's Word
</p>
<a href="/live" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors">
<i data-lucide="video" class="w-5 h-5"></i>
<span>Watch Live Services</span>
</a>
</div>
)}
</div>
</section>
<!-- Call to Action -->
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">Stay Connected</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-8">
Don't miss our weekly messages. Join us for live services or subscribe to our updates.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/live" class="inline-flex items-center space-x-2 bg-primary-600 text-white px-8 py-4 rounded-2xl font-semibold hover:bg-primary-700 transition-colors">
<i data-lucide="video" class="w-5 h-5"></i>
<span>Watch Live</span>
</a>
<a href="/events" class="inline-flex items-center space-x-2 border-2 border-primary-600 text-primary-600 dark:text-primary-400 px-8 py-4 rounded-2xl font-semibold hover:bg-primary-600 hover:text-white transition-colors">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span>View Events</span>
</a>
</div>
</div>
</section>
</MainLayout>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.filter-btn {
@apply px-6 py-3 rounded-xl font-medium transition-all duration-200 border-2;
@apply border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-300;
@apply hover:border-primary-500 hover:text-primary-600 dark:hover:text-primary-400;
}
.filter-btn.active {
@apply bg-primary-600 border-primary-600 text-white;
@apply hover:bg-primary-700 hover:border-primary-700;
}
.sermon-card.hidden {
display: none;
}
.sermon-card {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const filterButtons = document.querySelectorAll('.filter-btn');
const sermonCards = document.querySelectorAll('.sermon-card');
filterButtons.forEach(button => {
button.addEventListener('click', () => {
// Update active button
filterButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
const filter = button.dataset.filter;
// Filter sermon cards
sermonCards.forEach(card => {
const category = card.dataset.category;
const date = new Date(card.dataset.date);
const isRecent = (Date.now() - date.getTime()) < (90 * 24 * 60 * 60 * 1000); // 90 days
let shouldShow = false;
if (filter === 'sermons') {
shouldShow = category === 'sermon';
} else if (filter === 'livestream') {
shouldShow = category === 'livestream';
} else if (filter === 'recent') {
shouldShow = isRecent;
}
if (shouldShow) {
card.classList.remove('hidden');
} else {
card.classList.add('hidden');
}
});
});
});
});
</script>

View file

@ -0,0 +1,263 @@
---
import MainLayout from '../layouts/MainLayout.astro';
import { getChurchName } from '../lib/bindings.js';
let churchName = 'Church';
try {
churchName = getChurchName();
} catch (e) {
console.error('Failed to get church name:', e);
}
---
<MainLayout title={`Three Angels' Message - ${churchName}`} description="Discover the urgency and beauty of God's final message to the world - the Three Angels' Message of Revelation 14">
<!-- Hero Section -->
<section class="py-20 bg-gradient-to-br from-primary-900 via-primary-800 to-earth-900 text-white relative overflow-hidden">
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Cpath d="M30 30c0 16.569-13.431 30-30 30s-30-13.431-30-30 13.431-30 30-30 30 13.431 30 30z"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] animate-pulse"></div>
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="w-24 h-24 bg-gradient-to-br from-gold-400 to-gold-600 rounded-3xl flex items-center justify-center mx-auto mb-8 shadow-2xl">
<i data-lucide="users" class="w-12 h-12 text-white"></i>
</div>
<h1 class="text-5xl lg:text-7xl font-bold mb-6 text-golden-gradient">
The Three Angels' Message
</h1>
<p class="text-xl lg:text-2xl text-blue-100 max-w-4xl mx-auto mb-8 leading-relaxed">
God's final call of mercy and warning to a world in need.
<span class="text-gold-300 font-semibold">The everlasting gospel</span> proclaimed with power and urgency.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="#overview" class="inline-flex items-center space-x-2 bg-gold-500 text-black px-8 py-4 rounded-2xl font-semibold hover:bg-gold-400 transition-all duration-300 hover:scale-105 shadow-xl">
<i data-lucide="book-open" class="w-5 h-5"></i>
<span>Explore the Message</span>
</a>
<a href="/sermons?filter=three-angels" class="inline-flex items-center space-x-2 border-2 border-white text-white px-8 py-4 rounded-2xl font-semibold hover:bg-white hover:text-primary-900 transition-colors">
<i data-lucide="play" class="w-5 h-5"></i>
<span>Watch Sermons</span>
</a>
</div>
</div>
</section>
<!-- Scripture Foundation -->
<section id="overview" class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-6">
Revelation 14:6-12
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
The foundation of our faith and mission - God's final message to prepare the world for His second coming
</p>
</div>
<!-- Scripture Quote -->
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 lg:p-12 mb-16 border border-primary-100 dark:border-gray-600">
<div class="max-w-4xl mx-auto">
<div class="text-center mb-8">
<i data-lucide="quote" class="w-12 h-12 text-primary-500 mx-auto mb-4"></i>
</div>
<blockquote class="text-lg lg:text-xl text-gray-800 dark:text-gray-200 leading-relaxed italic text-center space-y-4">
<p>
"Then I saw another angel flying in midheaven, having the everlasting gospel to preach to those who dwell on the earth—to every nation, tribe, tongue, and people—saying with a loud voice,
<span class="text-primary-600 dark:text-primary-400 font-semibold">'Fear God and give glory to Him, for the hour of His judgment has come; and worship Him who made heaven and earth, the sea and springs of water.'</span>
</p>
<p>
And another angel followed, saying,
<span class="text-orange-600 dark:text-orange-400 font-semibold">'Babylon is fallen, is fallen, that great city, because she has made all nations drink of the wine of the wrath of her fornication.'</span>
</p>
<p>
Then a third angel followed them, saying with a loud voice,
<span class="text-red-600 dark:text-red-400 font-semibold">'If anyone worships the beast and his image, and receives his mark on his forehead or on his hand, he himself shall also drink of the wine of the wrath of God...'</span>
</p>
</blockquote>
<div class="text-center mt-8">
<cite class="text-primary-600 dark:text-primary-400 font-semibold text-lg">— Revelation 14:6-12 NKJV</cite>
</div>
</div>
</div>
</div>
</section>
<!-- The Three Angels -->
<section class="pt-20 pb-24 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-6">
Three Messages, One Purpose
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
Each angel brings a vital component of God's final appeal to humanity
</p>
</div>
<div class="grid lg:grid-cols-3 gap-8">
<!-- First Angel -->
<div class="bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-2 group">
<div class="h-2 bg-gradient-to-r from-primary-500 to-primary-600"></div>
<div class="p-8">
<div class="w-20 h-20 bg-gradient-to-br from-primary-500 to-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform">
<span class="text-3xl font-bold text-white">1</span>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4 text-center">
The Everlasting Gospel
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed text-center">
Fear God, give Him glory, worship the Creator. The hour of judgment has come.
</p>
<div class="space-y-3 mb-8">
<div class="flex items-center space-x-3">
<i data-lucide="heart" class="w-5 h-5 text-primary-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Fear & Reverence for God</span>
</div>
<div class="flex items-center space-x-3">
<i data-lucide="star" class="w-5 h-5 text-primary-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Give Glory to God</span>
</div>
<div class="flex items-center space-x-3">
<i data-lucide="gavel" class="w-5 h-5 text-primary-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Hour of Judgment</span>
</div>
<div class="flex items-center space-x-3">
<i data-lucide="globe" class="w-5 h-5 text-primary-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Worship the Creator</span>
</div>
</div>
<a href="/three-angels/first" class="w-full inline-flex items-center justify-center space-x-2 bg-primary-600 text-white px-6 py-3 rounded-xl font-medium hover:bg-primary-700 transition-colors shadow-lg hover:shadow-xl">
<span>Learn More</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
</div>
<!-- Second Angel -->
<div class="bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-2 group">
<div class="h-2 bg-gradient-to-r from-orange-500 to-orange-600"></div>
<div class="p-8">
<div class="w-20 h-20 bg-gradient-to-br from-orange-500 to-orange-600 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform">
<span class="text-3xl font-bold text-white">2</span>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4 text-center">
Babylon is Fallen
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed text-center">
A call to come out of false religious systems and return to biblical truth.
</p>
<div class="space-y-3 mb-8">
<div class="flex items-center space-x-3">
<i data-lucide="building" class="w-5 h-5 text-orange-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Fallen Religious Systems</span>
</div>
<div class="flex items-center space-x-3">
<i data-lucide="wine" class="w-5 h-5 text-orange-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Wine of Babylon</span>
</div>
<div class="flex items-center space-x-3">
<i data-lucide="door-open" class="w-5 h-5 text-orange-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Come Out of Her</span>
</div>
<div class="flex items-center space-x-3">
<i data-lucide="book" class="w-5 h-5 text-orange-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Return to Scripture</span>
</div>
</div>
<a href="/three-angels/second" class="w-full inline-flex items-center justify-center space-x-2 bg-orange-600 text-white px-6 py-3 rounded-xl font-medium hover:bg-orange-700 transition-colors shadow-lg hover:shadow-xl">
<span>Learn More</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
</div>
<!-- Third Angel -->
<div class="bg-white dark:bg-gray-900 rounded-3xl overflow-hidden shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-2 group">
<div class="h-2 bg-gradient-to-r from-red-500 to-red-600"></div>
<div class="p-8">
<div class="w-20 h-20 bg-gradient-to-br from-red-500 to-red-600 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform">
<span class="text-3xl font-bold text-white">3</span>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4 text-center">
Mark of the Beast
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed text-center">
A solemn warning against receiving the mark and a call to patient endurance.
</p>
<div class="space-y-3 mb-8">
<div class="flex items-center space-x-3">
<i data-lucide="shield-x" class="w-5 h-5 text-red-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Reject the Mark</span>
</div>
<div class="flex items-center space-x-3">
<i data-lucide="crown" class="w-5 h-5 text-red-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Beast & His Image</span>
</div>
<div class="flex items-center space-x-3">
<i data-lucide="hourglass" class="w-5 h-5 text-red-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Patient Endurance</span>
</div>
<div class="flex items-center space-x-3">
<i data-lucide="heart-handshake" class="w-5 h-5 text-red-500"></i>
<span class="text-sm text-gray-600 dark:text-gray-300">Faith of Jesus</span>
</div>
</div>
<a href="/three-angels/third" class="w-full inline-flex items-center justify-center space-x-2 bg-red-600 text-white px-6 py-3 rounded-xl font-medium hover:bg-red-700 transition-colors shadow-lg hover:shadow-xl">
<span>Learn More</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
</div>
</div>
</div>
</section>
<!-- Call to Action -->
<section class="pt-20 pb-20 bg-heavenly-gradient text-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl lg:text-5xl font-bold mb-6">Join the Message</h2>
<p class="text-xl text-blue-100 mb-8 leading-relaxed">
God is calling His people to proclaim these vital truths to the world.
Will you answer the call to share the Three Angels' Message?
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" class="inline-flex items-center space-x-2 bg-gold-500 text-black px-8 py-4 rounded-2xl font-semibold hover:bg-gold-400 transition-colors shadow-xl hover:shadow-2xl">
<i data-lucide="mail" class="w-5 h-5"></i>
<span>Get Involved</span>
</a>
<a href="/events" class="inline-flex items-center space-x-2 border-2 border-white text-white px-8 py-4 rounded-2xl font-semibold hover:bg-white hover:text-primary-900 transition-colors">
<i data-lucide="calendar" class="w-5 h-5"></i>
<span>Join Our Events</span>
</a>
</div>
</div>
</section>
</MainLayout>
<style>
.text-golden-gradient {
background: linear-gradient(135deg, #f59e0b, #d97706, #92400e);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
</style>

View file

@ -0,0 +1,350 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { getChurchName, fetchBibleVerseJson } from '../../lib/bindings.js';
let churchName = 'Church';
let revelation14_7 = null;
let revelation14_6 = null;
let psalm111_10 = null;
try {
churchName = getChurchName();
// Fetch Bible verses dynamically
const verse14_7 = JSON.parse(fetchBibleVerseJson('Revelation 14:7'));
revelation14_7 = verse14_7[0];
const verse14_6 = JSON.parse(fetchBibleVerseJson('Revelation 14:6'));
revelation14_6 = verse14_6[0];
const versePsalm = JSON.parse(fetchBibleVerseJson('Psalm 111:10'));
psalm111_10 = versePsalm[0];
} catch (e) {
console.error('Failed to load data:', e);
}
---
<MainLayout title={`First Angel's Message - ${churchName}`} description="Fear God and give glory to Him, for the hour of His judgment has come. Explore the first angel's message and its significance for our time.">
<!-- Navigation Breadcrumb -->
<section class="py-4 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<nav class="flex items-center space-x-2 text-sm">
<a href="/" class="text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400">Home</a>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<a href="/three-angels" class="text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400">Three Angels</a>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<span class="text-primary-600 dark:text-primary-400 font-medium">First Angel</span>
</nav>
</div>
</section>
<!-- Hero Section -->
<section class="py-20 bg-gradient-to-br from-primary-900 via-primary-800 to-blue-900 text-white relative overflow-hidden">
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.05"%3E%3Cpath d="M30 30c0 16.569-13.431 30-30 30s-30-13.431-30-30 13.431-30 30-30 30 13.431 30 30z"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-30"></div>
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<div class="w-24 h-24 bg-gradient-to-br from-primary-400 to-primary-600 rounded-3xl flex items-center justify-center mx-auto mb-8 shadow-2xl">
<span class="text-4xl font-bold text-white">1</span>
</div>
<h1 class="text-5xl lg:text-7xl font-bold mb-6">
The First Angel
</h1>
<p class="text-xl lg:text-2xl text-blue-100 max-w-4xl mx-auto leading-relaxed">
{revelation14_7 ? `"${revelation14_7.text}"` : '"Fear God and give glory to Him, for the hour of His judgment has come; and worship Him who made heaven and earth, the sea and springs of water."'}
</p>
<cite class="block mt-6 text-primary-300 font-semibold text-lg">— {revelation14_7?.reference || 'Revelation 14:7'}</cite>
</div>
</div>
</section>
<!-- Core Message -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- Content -->
<div>
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
The Everlasting Gospel
</h2>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
The first angel proclaims the <span class="text-primary-600 dark:text-primary-400 font-semibold">everlasting gospel</span>
to every nation, tribe, tongue, and people. This is not a new gospel, but the same good news that has been proclaimed
since Eden - salvation through faith in Jesus Christ.
</p>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
Yet this message comes with special urgency in the last days, calling all humanity to
<span class="text-primary-600 dark:text-primary-400 font-semibold">fear God, give Him glory, and worship the Creator</span>
as the hour of judgment arrives.
</p>
<div class="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6 border border-primary-100 dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Key Scripture</h3>
<blockquote class="text-primary-700 dark:text-primary-300 italic">
{revelation14_6 ? `"${revelation14_6.text}"` : '"Then I saw another angel flying in midheaven, having the everlasting gospel to preach to those who dwell on the earth—to every nation, tribe, tongue, and people"'}
</blockquote>
<cite class="text-sm text-gray-600 dark:text-gray-400 mt-2 block">{revelation14_6?.reference || 'Revelation 14:6'}</cite>
</div>
</div>
<!-- Visual Element -->
<div class="relative">
<div class="bg-gradient-to-br from-primary-100 to-blue-100 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 lg:p-12">
<div class="text-center">
<div class="w-32 h-32 bg-gradient-to-br from-primary-500 to-primary-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<i data-lucide="globe" class="w-16 h-16 text-white"></i>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">To All the World</h3>
<p class="text-gray-600 dark:text-gray-300">
The gospel message is universal - reaching every nation, tribe, tongue, and people on earth.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Four Key Elements -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-6">
Four Divine Commands
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
The first angel's message contains four specific calls that form the foundation of our relationship with God
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
<!-- Fear God -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="heart" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Fear God</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 text-sm leading-relaxed">
Not terror, but reverence and awe for our Creator. A healthy respect that leads to obedience and worship.
</p>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 italic">
{psalm111_10 ? `"${psalm111_10.text}" - ${psalm111_10.reference}` : '"The fear of the LORD is the beginning of wisdom" - Psalm 111:10'}
</p>
</div>
</div>
<!-- Give Glory -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-yellow-500 to-yellow-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="star" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Give Glory</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 text-sm leading-relaxed">
Honor God in all we do - our words, actions, and lifestyle should reflect His character and bring Him praise.
</p>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 italic">
"Whether you eat or drink... do all to the glory of God" - 1 Cor 10:31
</p>
</div>
</div>
<!-- Hour of Judgment -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="gavel" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Hour of Judgment</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 text-sm leading-relaxed">
The investigative judgment began in 1844. God is examining the lives and choices of all who have lived.
</p>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 italic">
"For we must all appear before the judgment seat" - 2 Cor 5:10
</p>
</div>
</div>
<!-- Worship Creator -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="globe" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Worship Creator</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 text-sm leading-relaxed">
Acknowledge God as the Creator of all. This includes keeping the Sabbath as a memorial of His creative power.
</p>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 italic">
"Remember the Sabbath day, to keep it holy" - Exodus 20:8
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Sabbath Connection -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- Visual -->
<div class="relative order-2 lg:order-1">
<div class="bg-gradient-to-br from-green-100 to-blue-100 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 lg:p-12">
<div class="text-center">
<div class="w-32 h-32 bg-gradient-to-br from-green-500 to-green-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<i data-lucide="sun" class="w-16 h-16 text-white"></i>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">The Sabbath Connection</h3>
<p class="text-gray-600 dark:text-gray-300">
Worship Him who made heaven and earth - a direct reference to the fourth commandment and the Sabbath.
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="order-1 lg:order-2">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
The Creator's Memorial
</h2>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
When the first angel calls us to "worship Him who made heaven and earth, the sea and springs of water,"
this is a direct echo of the <span class="text-primary-600 dark:text-primary-400 font-semibold">fourth commandment</span>
and God's memorial of creation - the Sabbath day.
</p>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
In the final crisis, the Sabbath will become a test of loyalty. Will we worship according to God's
commandment, or follow human traditions? The first angel's message prepares us for this choice.
</p>
<div class="bg-gradient-to-br from-green-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6 border border-green-100 dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">The Fourth Commandment</h3>
<blockquote class="text-green-700 dark:text-green-300 italic">
"Remember the Sabbath day, to keep it holy... For in six days the LORD made the heavens and the earth,
the sea, and all that is in them, and rested the seventh day."
</blockquote>
<cite class="text-sm text-gray-600 dark:text-gray-400 mt-2 block">Exodus 20:8-11</cite>
</div>
</div>
</div>
</div>
</section>
<!-- Application Today -->
<section class="py-20 bg-primary-50 dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
Living the First Angel's Message Today
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-12 leading-relaxed">
How do we apply this urgent message in our daily lives as we await Christ's return?
</p>
<div class="grid md:grid-cols-2 gap-8 text-left">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Personal Application</h3>
<ul class="space-y-4 text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-5 h-5 text-primary-500 flex-shrink-0"></i>
<span>Cultivate daily reverence for God through prayer and study</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-5 h-5 text-primary-500 flex-shrink-0"></i>
<span>Honor the Sabbath as God's memorial of creation</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-5 h-5 text-primary-500 flex-shrink-0"></i>
<span>Live a life that brings glory to God in all we do</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-5 h-5 text-primary-500 flex-shrink-0"></i>
<span>Prepare for judgment through Christ's righteousness</span>
</li>
</ul>
</div>
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Sharing the Message</h3>
<ul class="space-y-4 text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-5 h-5 text-primary-500 flex-shrink-0"></i>
<span>Proclaim the everlasting gospel to all nations</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-5 h-5 text-primary-500 flex-shrink-0"></i>
<span>Teach others about God's love and His commandments</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-5 h-5 text-primary-500 flex-shrink-0"></i>
<span>Warn of the coming judgment with love and urgency</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-5 h-5 text-primary-500 flex-shrink-0"></i>
<span>Be an example of Sabbath observance and worship</span>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Navigation to Other Angels -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Continue the Journey</h2>
<p class="text-lg text-gray-600 dark:text-gray-300">Explore the complete Three Angels' Message</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<a href="/three-angels" class="group bg-gradient-to-br from-gray-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<i data-lucide="users" class="w-12 h-12 text-primary-500 mx-auto mb-4 group-hover:scale-110 transition-transform"></i>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">Overview</h3>
<p class="text-gray-600 dark:text-gray-300">See all three messages together</p>
</a>
<a href="/three-angels/second" class="group bg-gradient-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div class="w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<span class="text-xl font-bold text-white">2</span>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">Second Angel</h3>
<p class="text-gray-600 dark:text-gray-300">Babylon is fallen</p>
</a>
<a href="/three-angels/third" class="group bg-gradient-to-br from-red-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div class="w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<span class="text-xl font-bold text-white">3</span>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">Third Angel</h3>
<p class="text-gray-600 dark:text-gray-300">Mark of the beast</p>
</a>
</div>
</div>
</section>
</MainLayout>

View file

@ -0,0 +1,389 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { getChurchName } from '../../lib/bindings.js';
let churchName = 'Church';
try {
churchName = getChurchName();
} catch (e) {
console.error('Failed to get church name:', e);
}
---
<MainLayout title={`Second Angel's Message - ${churchName}`} description="Babylon is fallen! Explore the second angel's message and God's call to come out of false religious systems.">
<!-- Navigation Breadcrumb -->
<section class="py-4 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<nav class="flex items-center space-x-2 text-sm">
<a href="/" class="text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400">Home</a>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<a href="/three-angels" class="text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400">Three Angels</a>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<span class="text-orange-600 dark:text-orange-400 font-medium">Second Angel</span>
</nav>
</div>
</section>
<!-- Hero Section -->
<section class="py-20 bg-gradient-to-br from-orange-900 via-orange-800 to-red-900 text-white relative overflow-hidden">
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.05"%3E%3Cpath d="M30 30c0 16.569-13.431 30-30 30s-30-13.431-30-30 13.431-30 30-30 30 13.431 30 30z"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-30"></div>
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<div class="w-24 h-24 bg-gradient-to-br from-orange-400 to-orange-600 rounded-3xl flex items-center justify-center mx-auto mb-8 shadow-2xl">
<span class="text-4xl font-bold text-white">2</span>
</div>
<h1 class="text-5xl lg:text-7xl font-bold mb-6">
The Second Angel
</h1>
<p class="text-xl lg:text-2xl text-orange-100 max-w-4xl mx-auto leading-relaxed">
"Babylon is fallen, is fallen, that great city, because she has made all nations
drink of the wine of the wrath of her fornication."
</p>
<cite class="block mt-6 text-orange-300 font-semibold text-lg">— Revelation 14:8</cite>
</div>
</div>
</section>
<!-- Core Message -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- Content -->
<div>
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
Babylon is Fallen
</h2>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
The second angel announces the spiritual fall of <span class="text-orange-600 dark:text-orange-400 font-semibold">Babylon</span> -
representing all false religious systems that have departed from biblical truth and embraced human traditions
over God's commandments.
</p>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
This message reveals how religious organizations have given the world the
<span class="text-orange-600 dark:text-orange-400 font-semibold">"wine of Babylon"</span> -
false doctrines that intoxicate people with error, leading them away from pure biblical worship.
</p>
<div class="bg-gradient-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6 border border-orange-100 dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Prophetic Connection</h3>
<blockquote class="text-orange-700 dark:text-orange-300 italic">
"Come out of her, my people, lest you share in her sins, and lest you receive of her plagues."
</blockquote>
<cite class="text-sm text-gray-600 dark:text-gray-400 mt-2 block">Revelation 18:4</cite>
</div>
</div>
<!-- Visual Element -->
<div class="relative">
<div class="bg-gradient-to-br from-orange-100 to-red-100 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 lg:p-12">
<div class="text-center">
<div class="w-32 h-32 bg-gradient-to-br from-orange-500 to-orange-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<i data-lucide="building" class="w-16 h-16 text-white"></i>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Spiritual Babylon</h3>
<p class="text-gray-600 dark:text-gray-300">
All religious systems that teach contrary to God's Word and His commandments.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- What is Babylon -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-6">
Understanding Modern Babylon
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
Babylon represents more than one church - it's a system of false worship that spans denominations
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<!-- False Doctrines -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
<div class="w-16 h-16 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="scroll" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4 text-center">False Doctrines</h3>
<ul class="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-red-500 flex-shrink-0"></i>
<span>Sunday worship instead of Sabbath</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-red-500 flex-shrink-0"></i>
<span>Immortality of the soul</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-red-500 flex-shrink-0"></i>
<span>Eternal torment in hell</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-red-500 flex-shrink-0"></i>
<span>Infant baptism</span>
</li>
</ul>
</div>
<!-- Human Traditions -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
<div class="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="users" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4 text-center">Human Traditions</h3>
<ul class="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-orange-500 flex-shrink-0"></i>
<span>Christmas and Easter celebrations</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-orange-500 flex-shrink-0"></i>
<span>Tradition over Scripture</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-orange-500 flex-shrink-0"></i>
<span>Papal authority over Bible</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-orange-500 flex-shrink-0"></i>
<span>Mary worship and saint intercession</span>
</li>
</ul>
</div>
<!-- Worldly Compromise -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
<div class="w-16 h-16 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="globe" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4 text-center">Worldly Compromise</h3>
<ul class="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-purple-500 flex-shrink-0"></i>
<span>Political alliances and power</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-purple-500 flex-shrink-0"></i>
<span>Materialism and wealth focus</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-purple-500 flex-shrink-0"></i>
<span>Entertainment over worship</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="x" class="w-4 h-4 text-purple-500 flex-shrink-0"></i>
<span>Compromise with sin</span>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- The Wine of Babylon -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- Visual -->
<div class="relative order-2 lg:order-1">
<div class="bg-gradient-to-br from-red-100 to-purple-100 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 lg:p-12">
<div class="text-center">
<div class="w-32 h-32 bg-gradient-to-br from-red-500 to-red-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<i data-lucide="wine" class="w-16 h-16 text-white"></i>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">The Wine of Wrath</h3>
<p class="text-gray-600 dark:text-gray-300">
False doctrines that intoxicate the mind and lead people away from God's truth.
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="order-1 lg:order-2">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
The Wine of Babylon
</h2>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
The <span class="text-orange-600 dark:text-orange-400 font-semibold">"wine"</span> represents false teachings
that intoxicate people's minds, making them unable to think clearly about spiritual truth.
Just as alcohol impairs judgment, these doctrines impair spiritual discernment.
</p>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
When churches teach traditions over Scripture, when they change God's law, when they offer
easy salvation without true repentance - this is the wine that makes "all nations" spiritually drunk.
</p>
<div class="bg-gradient-to-br from-red-50 to-orange-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6 border border-red-100 dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">The Great Deception</h3>
<blockquote class="text-red-700 dark:text-red-300 italic">
"For false christs and false prophets will rise and show great signs and wonders to deceive,
if possible, even the elect."
</blockquote>
<cite class="text-sm text-gray-600 dark:text-gray-400 mt-2 block">Matthew 24:24</cite>
</div>
</div>
</div>
</div>
</section>
<!-- Come Out Call -->
<section class="py-20 bg-orange-50 dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
"Come Out of Her, My People"
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-12 leading-relaxed">
God's loving call to leave false religious systems and return to biblical truth
</p>
<div class="grid md:grid-cols-2 gap-8 text-left">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Why Come Out?</h3>
<ul class="space-y-4 text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="heart" class="w-5 h-5 text-orange-500 flex-shrink-0"></i>
<span>God loves His people in every denomination</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="shield" class="w-5 h-5 text-orange-500 flex-shrink-0"></i>
<span>Protect yourself from receiving Babylon's plagues</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="book" class="w-5 h-5 text-orange-500 flex-shrink-0"></i>
<span>Follow the Bible alone as your guide</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="star" class="w-5 h-5 text-orange-500 flex-shrink-0"></i>
<span>Prepare for Christ's second coming</span>
</li>
</ul>
</div>
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">How to Come Out</h3>
<ul class="space-y-4 text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="search" class="w-5 h-5 text-orange-500 flex-shrink-0"></i>
<span>Study Scripture for yourself (Acts 17:11)</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="praying-hands" class="w-5 h-5 text-orange-500 flex-shrink-0"></i>
<span>Pray for wisdom and guidance</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="users" class="w-5 h-5 text-orange-500 flex-shrink-0"></i>
<span>Find fellowship with commandment-keeping believers</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="heart-handshake" class="w-5 h-5 text-orange-500 flex-shrink-0"></i>
<span>Love others while standing for truth</span>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Historical Fulfillment -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">Historical Fulfillment</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
This message began fulfillment during the Protestant Reformation and continues today
</p>
</div>
<div class="grid lg:grid-cols-3 gap-8">
<div class="bg-gradient-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="scroll" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Reformation Era</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">
Luther, Calvin, and other reformers began exposing papal errors and calling people back to Scripture.
</p>
</div>
<div class="bg-gradient-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">1844 Onward</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">
As the judgment hour message went forth, the second angel's message gained new urgency and clarity.
</p>
</div>
<div class="bg-gradient-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="clock" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Final Crisis</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">
Before Christ returns, this message will reach its climax as Babylon's true nature is fully revealed.
</p>
</div>
</div>
</div>
</section>
<!-- Navigation to Other Angels -->
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Continue the Journey</h2>
<p class="text-lg text-gray-600 dark:text-gray-300">Explore the complete Three Angels' Message</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<a href="/three-angels/first" class="group bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div class="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<span class="text-xl font-bold text-white">1</span>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">First Angel</h3>
<p class="text-gray-600 dark:text-gray-300">Fear God and give glory</p>
</a>
<a href="/three-angels" class="group bg-gradient-to-br from-gray-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<i data-lucide="users" class="w-12 h-12 text-primary-500 mx-auto mb-4 group-hover:scale-110 transition-transform"></i>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">Overview</h3>
<p class="text-gray-600 dark:text-gray-300">See all three messages together</p>
</a>
<a href="/three-angels/third" class="group bg-gradient-to-br from-red-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div class="w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<span class="text-xl font-bold text-white">3</span>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">Third Angel</h3>
<p class="text-gray-600 dark:text-gray-300">Mark of the beast</p>
</a>
</div>
</div>
</section>
</MainLayout>

View file

@ -0,0 +1,440 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { getChurchName } from '../../lib/bindings.js';
let churchName = 'Church';
try {
churchName = getChurchName();
} catch (e) {
console.error('Failed to get church name:', e);
}
---
<MainLayout title={`Third Angel's Message - ${churchName}`} description="A solemn warning against receiving the mark of the beast. Explore the third angel's message and the call to patient endurance.">
<!-- Navigation Breadcrumb -->
<section class="py-4 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<nav class="flex items-center space-x-2 text-sm">
<a href="/" class="text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400">Home</a>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<a href="/three-angels" class="text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400">Three Angels</a>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<span class="text-red-600 dark:text-red-400 font-medium">Third Angel</span>
</nav>
</div>
</section>
<!-- Hero Section -->
<section class="py-20 bg-gradient-to-br from-red-900 via-red-800 to-pink-900 text-white relative overflow-hidden">
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.05"%3E%3Cpath d="M30 30c0 16.569-13.431 30-30 30s-30-13.431-30-30 13.431-30 30-30 30 13.431 30 30z"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-30"></div>
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<div class="w-24 h-24 bg-gradient-to-br from-red-400 to-red-600 rounded-3xl flex items-center justify-center mx-auto mb-8 shadow-2xl">
<span class="text-4xl font-bold text-white">3</span>
</div>
<h1 class="text-5xl lg:text-7xl font-bold mb-6">
The Third Angel
</h1>
<p class="text-xl lg:text-2xl text-red-100 max-w-4xl mx-auto leading-relaxed mb-6">
"If anyone worships the beast and his image, and receives his mark on his forehead or on his hand,
he himself shall also drink of the wine of the wrath of God..."
</p>
<cite class="block text-red-300 font-semibold text-lg">— Revelation 14:9-10</cite>
</div>
</div>
</section>
<!-- Core Message -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- Content -->
<div>
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
The Final Warning
</h2>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
The third angel delivers the most solemn warning in all of Scripture - against receiving the
<span class="text-red-600 dark:text-red-400 font-semibold">mark of the beast</span>.
This message reveals the final test that will determine every person's eternal destiny.
</p>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
Those who receive the mark will face God's wrath without mixture, while those who refuse
demonstrate the <span class="text-red-600 dark:text-red-400 font-semibold">patience of the saints</span> -
keeping God's commandments and maintaining faith in Jesus.
</p>
<div class="bg-gradient-to-br from-red-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6 border border-red-100 dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">The Saints' Response</h3>
<blockquote class="text-red-700 dark:text-red-300 italic">
"Here is the patience of the saints; here are those who keep the commandments of God and the faith of Jesus."
</blockquote>
<cite class="text-sm text-gray-600 dark:text-gray-400 mt-2 block">Revelation 14:12</cite>
</div>
</div>
<!-- Visual Element -->
<div class="relative">
<div class="bg-gradient-to-br from-red-100 to-pink-100 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 lg:p-12">
<div class="text-center">
<div class="w-32 h-32 bg-gradient-to-br from-red-500 to-red-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<i data-lucide="shield-x" class="w-16 h-16 text-white"></i>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">The Great Test</h3>
<p class="text-gray-600 dark:text-gray-300">
Every person will face the choice between God's seal and the beast's mark.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Understanding the Mark -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-6">
The Mark of the Beast
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
Understanding what the mark is and how it relates to worship and God's law
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<!-- What is the Mark -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
<div class="w-16 h-16 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="bookmark" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4 text-center">What is the Mark?</h3>
<ul class="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="circle" class="w-4 h-4 text-red-500 flex-shrink-0"></i>
<span>A sign of allegiance to the beast power</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="circle" class="w-4 h-4 text-red-500 flex-shrink-0"></i>
<span>Enforced worship contrary to God's law</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="circle" class="w-4 h-4 text-red-500 flex-shrink-0"></i>
<span>Sunday worship enforcement</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="circle" class="w-4 h-4 text-red-500 flex-shrink-0"></i>
<span>A test of loyalty in the final crisis</span>
</li>
</ul>
</div>
<!-- Forehead vs Hand -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
<div class="w-16 h-16 bg-gradient-to-br from-orange-500 to-red-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="brain" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4 text-center">Forehead vs Hand</h3>
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-300">
<div class="bg-red-50 dark:bg-gray-800 p-3 rounded-lg">
<p class="font-semibold text-red-700 dark:text-red-400 mb-1">Forehead (Mind)</p>
<p>Accepting false worship intellectually and willingly</p>
</div>
<div class="bg-orange-50 dark:bg-gray-800 p-3 rounded-lg">
<p class="font-semibold text-orange-700 dark:text-orange-400 mb-1">Hand (Actions)</p>
<p>Conforming to false worship for convenience or fear</p>
</div>
</div>
</div>
<!-- The Alternative -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
<div class="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="shield-check" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4 text-center">God's Seal</h3>
<ul class="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-4 h-4 text-green-500 flex-shrink-0"></i>
<span>Sabbath observance</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-4 h-4 text-green-500 flex-shrink-0"></i>
<span>Keeping God's commandments</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-4 h-4 text-green-500 flex-shrink-0"></i>
<span>Faith in Jesus</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="check" class="w-4 h-4 text-green-500 flex-shrink-0"></i>
<span>Character like Christ</span>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- The Beast Power -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- Visual -->
<div class="relative order-2 lg:order-1">
<div class="bg-gradient-to-br from-purple-100 to-red-100 dark:from-gray-800 dark:to-gray-700 rounded-3xl p-8 lg:p-12">
<div class="text-center">
<div class="w-32 h-32 bg-gradient-to-br from-purple-500 to-red-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
<i data-lucide="crown" class="w-16 h-16 text-white"></i>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">The Beast Power</h3>
<p class="text-gray-600 dark:text-gray-300">
A religious-political system that claims authority over God's law and demands worship.
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="order-1 lg:order-2">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
Identifying the Beast
</h2>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
Scripture provides clear identifying marks of the beast power. This system combines religious and
political authority, claims to change God's times and laws, and demands worship that contradicts
biblical commandments.
</p>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
The central issue is <span class="text-red-600 dark:text-red-400 font-semibold">worship</span> -
whom will we obey when human authority conflicts with God's commandments?
The Sabbath becomes the final test of loyalty.
</p>
<div class="bg-gradient-to-br from-purple-50 to-red-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-6 border border-purple-100 dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">The Little Horn</h3>
<blockquote class="text-purple-700 dark:text-purple-300 italic">
"He shall speak pompous words against the Most High... and shall intend to change times and law."
</blockquote>
<cite class="text-sm text-gray-600 dark:text-gray-400 mt-2 block">Daniel 7:25</cite>
</div>
</div>
</div>
</div>
</section>
<!-- Patient Endurance -->
<section class="py-20 bg-green-50 dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
The Patience of the Saints
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-12 leading-relaxed">
In contrast to those who receive the mark, God's people demonstrate patient endurance
</p>
<div class="grid md:grid-cols-2 gap-8 text-left">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Keep God's Commandments</h3>
<ul class="space-y-4 text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="heart" class="w-5 h-5 text-green-500 flex-shrink-0"></i>
<span>Love God supremely (First four commandments)</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="users" class="w-5 h-5 text-green-500 flex-shrink-0"></i>
<span>Love others as yourself (Last six commandments)</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="sun" class="w-5 h-5 text-green-500 flex-shrink-0"></i>
<span>Keep the Sabbath holy (Fourth commandment)</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="shield" class="w-5 h-5 text-green-500 flex-shrink-0"></i>
<span>Obey God rather than man (Acts 5:29)</span>
</li>
</ul>
</div>
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Faith of Jesus</h3>
<ul class="space-y-4 text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="cross" class="w-5 h-5 text-green-500 flex-shrink-0"></i>
<span>Trust in Christ's righteousness alone</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="heart-handshake" class="w-5 h-5 text-green-500 flex-shrink-0"></i>
<span>The same faith that Jesus had</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="mountain" class="w-5 h-5 text-green-500 flex-shrink-0"></i>
<span>Unwavering trust despite persecution</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="crown" class="w-5 h-5 text-green-500 flex-shrink-0"></i>
<span>Faithful unto death for eternal life</span>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- The Final Crisis -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">The Final Crisis</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
How the third angel's message will be fulfilled in the last days
</p>
</div>
<div class="grid lg:grid-cols-3 gap-8">
<div class="bg-gradient-to-br from-red-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="gavel" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Sunday Law</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">
Laws will be passed requiring Sunday observance, making it illegal to keep the Sabbath.
</p>
</div>
<div class="bg-gradient-to-br from-red-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="ban" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Economic Boycott</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">
Those refusing the mark will be unable to buy or sell, facing economic persecution.
</p>
</div>
<div class="bg-gradient-to-br from-red-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center mx-auto mb-6">
<i data-lucide="sword" class="w-8 h-8 text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Death Decree</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm">
Finally, a death decree will be issued against all who refuse to receive the mark.
</p>
</div>
</div>
</div>
</section>
<!-- Preparation -->
<section class="py-20 bg-red-50 dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-6">
How to Prepare
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-12 leading-relaxed">
The time to prepare for the final test is now, while mercy still lingers
</p>
<div class="grid md:grid-cols-2 gap-8 text-left">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Spiritual Preparation</h3>
<ul class="space-y-4 text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="book-open" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
<span>Study God's Word daily</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="praying-hands" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
<span>Develop a strong prayer life</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="sun" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
<span>Honor the Sabbath faithfully</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="heart" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
<span>Surrender completely to Jesus</span>
</li>
</ul>
</div>
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Character Development</h3>
<ul class="space-y-4 text-gray-600 dark:text-gray-300">
<li class="flex items-center space-x-3">
<i data-lucide="shield" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
<span>Stand for truth regardless of consequences</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="heart-handshake" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
<span>Practice patient endurance now</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="users" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
<span>Share the Three Angels' Message</span>
</li>
<li class="flex items-center space-x-3">
<i data-lucide="crown" class="w-5 h-5 text-red-500 flex-shrink-0"></i>
<span>Trust God's promises completely</span>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Navigation to Other Angels -->
<section class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Continue the Journey</h2>
<p class="text-lg text-gray-600 dark:text-gray-300">Explore the complete Three Angels' Message</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<a href="/three-angels/first" class="group bg-gradient-to-br from-primary-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div class="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<span class="text-xl font-bold text-white">1</span>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">First Angel</h3>
<p class="text-gray-600 dark:text-gray-300">Fear God and give glory</p>
</a>
<a href="/three-angels/second" class="group bg-gradient-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div class="w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<span class="text-xl font-bold text-white">2</span>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">Second Angel</h3>
<p class="text-gray-600 dark:text-gray-300">Babylon is fallen</p>
</a>
<a href="/three-angels" class="group bg-gradient-to-br from-gray-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 text-center hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<i data-lucide="users" class="w-12 h-12 text-primary-500 mx-auto mb-4 group-hover:scale-110 transition-transform"></i>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">Overview</h3>
<p class="text-gray-600 dark:text-gray-300">See all three messages together</p>
</a>
</div>
</div>
</section>
</MainLayout>

View file

@ -0,0 +1,97 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
// Three Angels' Message inspired palette
primary: {
50: '#f0f4ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1', // Primary indigo
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4b'
},
// Sacred gold for highlights
gold: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b', // Gold
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f'
},
// Pure whites and deep darks for contrast
heaven: {
50: '#ffffff',
100: '#fefefe',
200: '#fafafa',
300: '#f4f4f5'
},
earth: {
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712'
}
},
fontFamily: {
// Modern, readable fonts
'display': ['Inter', 'system-ui', 'sans-serif'],
'body': ['Inter', 'system-ui', 'sans-serif'],
'serif': ['Playfair Display', 'Georgia', 'serif']
},
fontSize: {
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['1rem', { lineHeight: '1.5rem' }],
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
'5xl': ['3rem', { lineHeight: '1' }],
'6xl': ['3.75rem', { lineHeight: '1' }],
'7xl': ['4.5rem', { lineHeight: '1' }],
'8xl': ['6rem', { lineHeight: '1' }],
'9xl': ['8rem', { lineHeight: '1' }]
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.6s ease-out',
'float': 'float 6s ease-in-out infinite'
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' }
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(30px)' },
'100%': { opacity: '1', transform: 'translateY(0)' }
},
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-20px)' }
}
},
boxShadow: {
'soft': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'medium': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
'large': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'divine': '0 0 50px rgba(99, 102, 241, 0.3)'
}
}
},
plugins: []
}

View file

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

113
church-core/Cargo.toml Normal file
View file

@ -0,0 +1,113 @@
[package]
name = "church-core"
version = "0.1.0"
edition = "2021"
description = "Shared Rust crate for church application APIs and data models"
authors = ["Benjamin Slingo <benjamin@example.com>"]
license = "MIT"
[dependencies]
# HTTP client (using rustls to avoid OpenSSL cross-compilation issues)
reqwest = { version = "0.11", features = ["json", "multipart", "stream", "rustls-tls"], default-features = false }
tokio = { version = "1.0", features = ["full"] }
# JSON handling
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Date/time handling
chrono = { version = "0.4", features = ["serde"] }
# Error handling
thiserror = "1.0"
anyhow = "1.0"
# Caching and utilities
moka = { version = "0.12", features = ["future"] }
async-trait = "0.1"
rand = "0.8"
urlencoding = "2.1"
# UUID generation
uuid = { version = "1.0", features = ["v4", "serde"] }
# Base64 encoding for image caching
base64 = "0.21"
# URL handling
url = "2.4"
# Regular expressions
regex = "1.10"
# System calls for iOS device detection
libc = "0.2"
# HTML processing
html2text = "0.12"
# UniFFI for mobile bindings
uniffi = { version = "0.27", features = ["tokio"] }
# Build dependencies
[build-dependencies]
uniffi = { version = "0.27", features = ["build"], optional = true }
uniffi_bindgen = { version = "0.27", features = ["clap"] }
# Bin dependencies
[dependencies.uniffi_bindgen_dep]
package = "uniffi_bindgen"
version = "0.27"
optional = true
# Testing dependencies
[dev-dependencies]
tokio-test = "0.4"
mockito = "0.31"
serde_json = "1.0"
tempfile = "3.8"
pretty_assertions = "1.4"
# Optional FFI support
[dependencies.wasm-bindgen]
version = "0.2"
optional = true
[dependencies.wasm-bindgen-futures]
version = "0.4"
optional = true
[dependencies.js-sys]
version = "0.3"
optional = true
[dependencies.web-sys]
version = "0.3"
optional = true
features = [
"console",
"Window",
"Document",
"Element",
"HtmlElement",
"Storage",
"Request",
"RequestInit",
"Response",
"Headers",
]
[features]
default = ["native"]
native = []
wasm = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys"]
ffi = ["uniffi/tokio"]
uniffi = ["ffi", "uniffi/build"]
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[[bin]]
name = "church-core-test"
path = "src/bin/test.rs"

6
church-core/build.rs Normal file
View file

@ -0,0 +1,6 @@
fn main() {
#[cfg(feature = "uniffi")]
{
uniffi::generate_scaffolding("src/church_core.udl").unwrap();
}
}

View file

@ -0,0 +1,4 @@
// Authentication modules placeholder
// This contains authentication implementations
pub use crate::models::AuthToken;

View file

@ -0,0 +1,36 @@
use church_core::{
client::{ChurchApiClient, events::submit_event},
models::EventSubmission,
config::ChurchCoreConfig,
};
#[tokio::main]
async fn main() {
let config = ChurchCoreConfig::new();
let client = ChurchApiClient::new(config).unwrap();
let submission = EventSubmission {
title: "Test Event".to_string(),
description: "Testing date submission".to_string(),
start_time: "2025-06-28T23:00".to_string(), // The problematic format
end_time: "2025-06-29T00:00".to_string(),
location: "Test Location".to_string(),
location_url: None,
category: "Other".to_string(),
is_featured: false,
recurring_type: None,
bulletin_week: None,
submitter_email: "test@example.com".to_string(),
};
println!("Testing date validation:");
println!("Can parse start_time: {}", submission.parse_start_time().is_some());
println!("Can parse end_time: {}", submission.parse_end_time().is_some());
println!("Validation passes: {}", submission.validate_times());
println!("\nAttempting to submit event...");
match submit_event(&client, submission).await {
Ok(id) => println!("✅ Success! Event ID: {}", id),
Err(e) => println!("❌ Error: {}", e),
}
}

View file

@ -0,0 +1,94 @@
use church_core::{ChurchApiClient, ChurchCoreConfig, DeviceCapabilities, StreamingCapability};
use chrono::TimeZone;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize the client with default configuration
let config = ChurchCoreConfig::default();
let client = ChurchApiClient::new(config)?;
println!("Church Core API Client Test");
println!("==========================");
// Test health check
match client.health_check().await {
Ok(true) => println!("✅ Health check passed"),
Ok(false) => println!("❌ Health check failed"),
Err(e) => println!("❌ Health check error: {}", e),
}
// Test upcoming events
match client.get_upcoming_events(Some(5)).await {
Ok(events) => {
println!("✅ Retrieved {} upcoming events", events.len());
for event in events.iter().take(3) {
println!(" - {}: {}", event.title, event.start_time.format("%Y-%m-%d %H:%M"));
}
}
Err(e) => println!("❌ Failed to get events: {}", e),
}
// Test current bulletin
match client.get_current_bulletin().await {
Ok(Some(bulletin)) => {
println!("✅ Retrieved current bulletin: {}", bulletin.title);
}
Ok(None) => println!(" No current bulletin found"),
Err(e) => println!("❌ Failed to get bulletin: {}", e),
}
// Test configuration
match client.get_config().await {
Ok(config) => {
println!("✅ Retrieved church config");
if let Some(name) = &config.church_name {
println!(" Church: {}", name);
}
}
Err(e) => println!("❌ Failed to get config: {}", e),
}
// Test sermons
match client.get_recent_sermons(Some(5)).await {
Ok(sermons) => {
println!("✅ Retrieved {} recent sermons", sermons.len());
for sermon in sermons.iter().take(2) {
println!(" - {}: {}", sermon.title, sermon.speaker);
}
}
Err(e) => println!("❌ Failed to get sermons: {}", e),
}
// Test livestreams
match client.get_livestreams().await {
Ok(streams) => {
println!("✅ Retrieved {} livestream archives", streams.len());
for stream in streams.iter().take(2) {
println!(" - {}: {}", stream.title, stream.speaker);
}
}
Err(e) => println!("❌ Failed to get livestreams: {}", e),
}
// Test cache stats
let (cache_size, max_size) = client.get_cache_stats().await;
println!("📊 Cache: {}/{} items", cache_size, max_size);
// Test streaming URL generation
println!("\n🎬 Testing Streaming URLs:");
let media_id = "test-id-123";
let base_url = "https://api.rockvilletollandsda.church";
let av1_url = DeviceCapabilities::get_streaming_url(base_url, media_id, StreamingCapability::AV1);
println!(" AV1: {}", av1_url.url);
let hls_url = DeviceCapabilities::get_streaming_url(base_url, media_id, StreamingCapability::HLS);
println!(" HLS: {}", hls_url.url);
let optimal_url = DeviceCapabilities::get_optimal_streaming_url(base_url, media_id);
println!(" Optimal: {} ({:?})", optimal_url.url, optimal_url.capability);
println!("\nTest completed!");
Ok(())
}

339
church-core/src/cache/mod.rs vendored Normal file
View file

@ -0,0 +1,339 @@
use serde::{de::DeserializeOwned, Serialize, Deserialize};
use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
path::PathBuf,
};
use tokio::sync::RwLock;
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedHttpResponse {
pub data: Vec<u8>,
pub content_type: String,
pub headers: HashMap<String, String>,
pub status_code: u16,
#[serde(with = "instant_serde")]
pub cached_at: Instant,
#[serde(with = "instant_serde")]
pub expires_at: Instant,
}
// Custom serializer for Instant (can't be serialized directly)
mod instant_serde {
use super::*;
use serde::{Deserializer, Serializer};
pub fn serialize<S>(instant: &Instant, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// Convert to duration since app start - this is approximate but works for our use case
let duration_since_start = instant.elapsed();
serializer.serialize_u64(duration_since_start.as_secs())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Instant, D::Error>
where
D: Deserializer<'de>,
{
let secs = <u64 as Deserialize>::deserialize(deserializer)?;
// For loaded items, set as if they were cached "now" minus the stored duration
// This isn't perfect but works for expiration checking
Ok(Instant::now() - Duration::from_secs(secs))
}
}
// Simplified cache interface - removed trait object complexity
// Each cache type will implement these methods directly
#[derive(Debug)]
struct CacheEntry {
data: Vec<u8>,
expires_at: Instant,
}
impl CacheEntry {
fn new(data: Vec<u8>, ttl: Duration) -> Self {
Self {
data,
expires_at: Instant::now() + ttl,
}
}
fn is_expired(&self) -> bool {
Instant::now() > self.expires_at
}
}
pub struct MemoryCache {
store: Arc<RwLock<HashMap<String, CacheEntry>>>,
http_store: Arc<RwLock<HashMap<String, CachedHttpResponse>>>,
max_size: usize,
cache_dir: Option<PathBuf>,
}
impl MemoryCache {
pub fn new(max_size: usize) -> Self {
Self {
store: Arc::new(RwLock::new(HashMap::new())),
http_store: Arc::new(RwLock::new(HashMap::new())),
max_size,
cache_dir: None,
}
}
pub fn with_disk_cache(mut self, cache_dir: PathBuf) -> Self {
self.cache_dir = Some(cache_dir);
self
}
fn get_cache_file_path(&self, url: &str) -> Option<PathBuf> {
self.cache_dir.as_ref().map(|dir| {
// Create a safe filename from URL
let hash = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
url.hash(&mut hasher);
hasher.finish()
};
dir.join(format!("cache_{}.json", hash))
})
}
async fn cleanup_expired(&self) {
let mut store = self.store.write().await;
let now = Instant::now();
store.retain(|_, entry| entry.expires_at > now);
}
async fn ensure_capacity(&self) {
let mut store = self.store.write().await;
if store.len() >= self.max_size {
// Remove oldest entries if we're at capacity
// Collect keys to remove to avoid borrow issues
let mut to_remove: Vec<String> = Vec::new();
{
let entries: Vec<_> = store.iter().collect();
let mut sorted_entries = entries;
sorted_entries.sort_by_key(|(_, entry)| entry.expires_at);
let remove_count = sorted_entries.len().saturating_sub(self.max_size / 2);
for (key, _) in sorted_entries.into_iter().take(remove_count) {
to_remove.push(key.clone());
}
}
// Now remove the keys
for key in to_remove {
store.remove(&key);
}
}
}
}
impl MemoryCache {
pub async fn get<T>(&self, key: &str) -> Option<T>
where
T: DeserializeOwned + Send + 'static,
{
// Clean up expired entries periodically
if rand::random::<f32>() < 0.1 {
self.cleanup_expired().await;
}
let store = self.store.read().await;
if let Some(entry) = store.get(key) {
if !entry.is_expired() {
if let Ok(value) = serde_json::from_slice(&entry.data) {
return Some(value);
}
}
}
None
}
pub async fn set<T>(&self, key: &str, value: &T, ttl: Duration)
where
T: Serialize + Send + Sync,
{
if let Ok(data) = serde_json::to_vec(value) {
self.ensure_capacity().await;
let mut store = self.store.write().await;
store.insert(key.to_string(), CacheEntry::new(data, ttl));
}
}
pub async fn remove(&self, key: &str) {
let mut store = self.store.write().await;
store.remove(key);
}
pub async fn clear(&self) {
let mut store = self.store.write().await;
store.clear();
}
pub async fn len(&self) -> usize {
let store = self.store.read().await;
store.len()
}
pub async fn invalidate_prefix(&self, prefix: &str) {
let mut store = self.store.write().await;
store.retain(|key, _| !key.starts_with(prefix));
let mut http_store = self.http_store.write().await;
http_store.retain(|key, _| !key.starts_with(prefix));
}
// HTTP Response Caching Methods
pub async fn get_http_response(&self, url: &str) -> Option<CachedHttpResponse> {
// Clean up expired entries periodically
if rand::random::<f32>() < 0.1 {
self.cleanup_expired_http().await;
}
// 1. Check memory cache first (fastest)
{
let store = self.http_store.read().await;
println!("🔍 Memory cache lookup for: {}", url);
println!("🔍 Memory cache has {} entries", store.len());
if let Some(response) = store.get(url) {
if !response.is_expired() {
println!("🔍 Memory cache HIT - found valid entry");
return Some(response.clone());
} else {
println!("🔍 Memory cache entry expired");
}
}
}
// 2. Check disk cache (persistent)
if let Some(cache_path) = self.get_cache_file_path(url) {
println!("🔍 Checking disk cache at: {:?}", cache_path);
if let Ok(file_content) = fs::read(&cache_path).await {
if let Ok(cached_response) = serde_json::from_slice::<CachedHttpResponse>(&file_content) {
if !cached_response.is_expired() {
println!("🔍 Disk cache HIT - loading into memory");
// Load back into memory cache for faster future access
let mut store = self.http_store.write().await;
store.insert(url.to_string(), cached_response.clone());
return Some(cached_response);
} else {
println!("🔍 Disk cache entry expired, removing file");
let _ = fs::remove_file(&cache_path).await;
}
} else {
println!("🔍 Failed to parse disk cache file");
}
} else {
println!("🔍 No disk cache file found");
}
}
println!("🔍 Cache MISS - no valid entry found");
None
}
pub async fn set_http_response(&self, url: &str, response: CachedHttpResponse) {
self.ensure_http_capacity().await;
// Store in memory cache
let mut store = self.http_store.write().await;
println!("🔍 Storing in memory cache: {}", url);
println!("🔍 Memory cache will have {} entries after insert", store.len() + 1);
store.insert(url.to_string(), response.clone());
drop(store); // Release the lock before async disk operation
// Store in disk cache (async, non-blocking)
if let Some(cache_path) = self.get_cache_file_path(url) {
println!("🔍 Storing to disk cache: {:?}", cache_path);
// Ensure cache directory exists
if let Some(parent) = cache_path.parent() {
let _ = fs::create_dir_all(parent).await;
}
// Serialize and save to disk
match serde_json::to_vec(&response) {
Ok(serialized) => {
if let Err(e) = fs::write(&cache_path, serialized).await {
println!("🔍 Failed to write disk cache: {}", e);
} else {
println!("🔍 Successfully saved to disk cache");
}
}
Err(e) => {
println!("🔍 Failed to serialize for disk cache: {}", e);
}
}
}
}
async fn cleanup_expired_http(&self) {
let mut store = self.http_store.write().await;
let now = Instant::now();
store.retain(|_, response| response.expires_at > now);
}
async fn ensure_http_capacity(&self) {
let mut store = self.http_store.write().await;
if store.len() >= self.max_size {
// Remove oldest entries if we're at capacity
let mut to_remove: Vec<String> = Vec::new();
{
let entries: Vec<_> = store.iter().collect();
let mut sorted_entries = entries;
sorted_entries.sort_by_key(|(_, response)| response.cached_at);
let remove_count = sorted_entries.len().saturating_sub(self.max_size / 2);
for (key, _) in sorted_entries.into_iter().take(remove_count) {
to_remove.push(key.clone());
}
}
// Now remove the keys
for key in to_remove {
store.remove(&key);
}
}
}
}
impl CachedHttpResponse {
pub fn new(
data: Vec<u8>,
content_type: String,
headers: HashMap<String, String>,
status_code: u16,
ttl: Duration
) -> Self {
let now = Instant::now();
Self {
data,
content_type,
headers,
status_code,
cached_at: now,
expires_at: now + ttl,
}
}
pub fn is_expired(&self) -> bool {
Instant::now() > self.expires_at
}
}
// Add rand dependency for periodic cleanup
// This is a simple implementation - in production you might want to use a more sophisticated cache like moka

View file

@ -0,0 +1,76 @@
namespace church_core {
string fetch_events_json();
string fetch_bulletins_json();
string fetch_sermons_json();
string fetch_bible_verse_json(string query);
string fetch_random_bible_verse_json();
string fetch_scripture_verses_for_sermon_json(string sermon_id);
string fetch_config_json();
string fetch_current_bulletin_json();
string fetch_featured_events_json();
string fetch_stream_status_json();
boolean get_stream_live_status();
string get_livestream_url();
string fetch_live_stream_json();
string fetch_livestream_archive_json();
string submit_contact_json(string name, string email, string message);
string submit_contact_v2_json(string name, string email, string subject, string message, string phone);
string submit_contact_v2_json_legacy(string first_name, string last_name, string email, string subject, string message);
string fetch_cached_image_base64(string url);
string get_optimal_streaming_url(string media_id);
boolean device_supports_av1();
string get_av1_streaming_url(string media_id);
string get_hls_streaming_url(string media_id);
// Scripture formatting utilities
string format_scripture_text_json(string scripture_text);
string extract_scripture_references_string(string scripture_text);
string create_sermon_share_items_json(string title, string speaker, string? video_url, string? audio_url);
// Form validation functions
string validate_contact_form_json(string form_json);
boolean validate_email_address(string email);
boolean validate_phone_number(string phone);
// Event formatting functions
string format_event_for_display_json(string event_json);
string format_time_range_string(string start_time, string end_time);
boolean is_multi_day_event_check(string date);
// Home feed aggregation
string generate_home_feed_json(string events_json, string sermons_json, string bulletins_json, string verse_json);
// Media type management
string get_media_type_display_name(string media_type_str);
string get_media_type_icon(string media_type_str);
string filter_sermons_by_media_type(string sermons_json, string media_type_str);
// Individual config getter functions (RTSDA architecture compliant)
string get_church_name();
string get_contact_phone();
string get_contact_email();
string get_brand_color();
string get_about_text();
string get_donation_url();
string get_church_address();
sequence<f64> get_coordinates();
string get_website_url();
string get_facebook_url();
string get_youtube_url();
string get_instagram_url();
string get_mission_statement();
// Calendar event parsing (RTSDA architecture compliant)
string create_calendar_event_data(string event_json);
// JSON parsing functions (RTSDA architecture compliance)
string parse_events_from_json(string events_json);
string parse_sermons_from_json(string sermons_json);
string parse_bulletins_from_json(string bulletins_json);
string parse_bible_verse_from_json(string verse_json);
string parse_contact_result_from_json(string result_json);
string generate_verse_description(string verses_json);
string extract_full_verse_text(string verses_json);
string extract_stream_url_from_status(string status_json);
string parse_calendar_event_data(string calendar_json);
};

View file

@ -0,0 +1,109 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{
NewBulletin, BulletinUpdate,
NewEvent, EventUpdate, PendingEvent,
User, Schedule, NewSchedule, ScheduleUpdate,
ApiVersion,
},
};
// Admin Bulletin Management
pub async fn create_bulletin(client: &ChurchApiClient, bulletin: NewBulletin) -> Result<String> {
client.post_api_with_version("/admin/bulletins", &bulletin, ApiVersion::V1).await
}
pub async fn update_bulletin(client: &ChurchApiClient, id: &str, update: BulletinUpdate) -> Result<()> {
let path = format!("/admin/bulletins/{}", id);
client.put_api(&path, &update).await
}
pub async fn delete_bulletin(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/bulletins/{}", id);
client.delete_api(&path).await
}
// Admin Event Management
pub async fn create_admin_event(client: &ChurchApiClient, event: NewEvent) -> Result<String> {
client.post_api_with_version("/admin/events", &event, ApiVersion::V1).await
}
pub async fn update_admin_event(client: &ChurchApiClient, id: &str, update: EventUpdate) -> Result<()> {
let path = format!("/admin/events/{}", id);
client.put_api(&path, &update).await
}
pub async fn delete_admin_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/events/{}", id);
client.delete_api(&path).await
}
// Admin Pending Events Management
pub async fn get_pending_events(client: &ChurchApiClient) -> Result<Vec<PendingEvent>> {
client.get_api("/admin/events/pending").await
}
pub async fn approve_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/events/pending/{}/approve", id);
let response: crate::models::ApiResponse<()> = client.post(&path, &()).await?;
if response.success {
Ok(())
} else {
Err(crate::error::ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Failed to approve pending event".to_string())
))
}
}
pub async fn reject_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/events/pending/{}/reject", id);
let response: crate::models::ApiResponse<()> = client.post(&path, &()).await?;
if response.success {
Ok(())
} else {
Err(crate::error::ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Failed to reject pending event".to_string())
))
}
}
pub async fn delete_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/admin/events/pending/{}", id);
client.delete_api(&path).await
}
// Admin User Management
pub async fn get_users(client: &ChurchApiClient) -> Result<Vec<User>> {
client.get_api("/admin/users").await
}
// Admin Schedule Management
pub async fn create_schedule(client: &ChurchApiClient, schedule: NewSchedule) -> Result<String> {
client.post_api("/admin/schedule", &schedule).await
}
pub async fn update_schedule(client: &ChurchApiClient, date: &str, update: ScheduleUpdate) -> Result<()> {
let path = format!("/admin/schedule/{}", date);
client.put_api(&path, &update).await
}
pub async fn delete_schedule(client: &ChurchApiClient, date: &str) -> Result<()> {
let path = format!("/admin/schedule/{}", date);
client.delete_api(&path).await
}
pub async fn get_all_schedules(client: &ChurchApiClient) -> Result<Vec<Schedule>> {
client.get_api("/admin/schedule").await
}
// Admin Config Management
pub async fn get_admin_config(client: &ChurchApiClient) -> Result<crate::models::ChurchConfig> {
client.get_api("/admin/config").await
}

View file

@ -0,0 +1,169 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{BibleVerse, VerseOfTheDay, VerseCategory, PaginationParams, ApiListResponse, ApiVersion},
};
pub async fn get_random_verse(client: &ChurchApiClient) -> Result<BibleVerse> {
// The response format is {success: bool, data: Verse}
#[derive(serde::Deserialize, serde::Serialize)]
struct VerseResponse {
success: bool,
data: ApiVerse,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct ApiVerse {
id: String,
reference: String,
text: String,
#[serde(rename = "is_active")]
is_active: bool,
}
let url = client.build_url("/bible_verses/random");
let raw_response = client.client.get(&url).send().await?;
let response_text = raw_response.text().await?;
let response: VerseResponse = serde_json::from_str(&response_text)
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
if response.success {
Ok(BibleVerse::new(response.data.text, response.data.reference))
} else {
Err(crate::error::ChurchApiError::Api("Bible verse API returned success=false".to_string()))
}
}
pub async fn get_verse_of_the_day(client: &ChurchApiClient) -> Result<VerseOfTheDay> {
client.get_api("/bible/verse-of-the-day").await
}
pub async fn get_verse_by_reference(client: &ChurchApiClient, reference: &str) -> Result<Option<BibleVerse>> {
let path = format!("/bible/verse?reference={}", urlencoding::encode(reference));
match client.get_api(&path).await {
Ok(verse) => Ok(Some(verse)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn get_verses_by_category(client: &ChurchApiClient, category: VerseCategory, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
let mut path = format!("/bible/category/{}", category.display_name().to_lowercase());
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
let response: crate::models::ApiListResponse<BibleVerse> = client.get_api_list(&path).await?;
Ok(response.data.items)
}
pub async fn search_verses(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
let mut path = format!("/bible_verses/search?q={}", urlencoding::encode(query));
if let Some(limit) = limit {
path.push_str(&format!("&limit={}", limit));
}
// The bible_verses/search endpoint returns a custom format with additional fields
#[derive(serde::Deserialize, serde::Serialize)]
struct ApiBibleVerse {
id: String,
reference: String,
text: String,
is_active: bool,
created_at: String,
updated_at: String,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct BibleSearchResponse {
success: bool,
data: Vec<ApiBibleVerse>,
message: Option<String>,
}
let url = client.build_url(&path);
let raw_response = client.client.get(&url).send().await?;
let response_text = raw_response.text().await?;
let response: BibleSearchResponse = serde_json::from_str(&response_text)
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
if response.success {
// Convert ApiBibleVerse to BibleVerse
let verses = response.data.into_iter()
.map(|api_verse| BibleVerse::new(api_verse.text, api_verse.reference))
.collect();
Ok(verses)
} else {
Ok(Vec::new())
}
}
// V2 API methods
pub async fn get_random_verse_v2(client: &ChurchApiClient) -> Result<BibleVerse> {
#[derive(serde::Deserialize, serde::Serialize)]
struct VerseResponse {
success: bool,
data: ApiVerse,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct ApiVerse {
id: String,
reference: String,
text: String,
#[serde(rename = "is_active")]
is_active: bool,
}
let url = client.build_url_with_version("/bible_verses/random", ApiVersion::V2);
let raw_response = client.client.get(&url).send().await?;
let response_text = raw_response.text().await?;
let response: VerseResponse = serde_json::from_str(&response_text)
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
if response.success {
Ok(BibleVerse::new(response.data.text, response.data.reference))
} else {
Err(crate::error::ChurchApiError::Api("Bible verse API returned success=false".to_string()))
}
}
pub async fn get_bible_verses_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<BibleVerse>> {
let mut path = "/bible_verses".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list_with_version(&path, ApiVersion::V2).await
}
pub async fn search_verses_v2(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
let mut path = format!("/bible_verses/search?q={}", urlencoding::encode(query));
if let Some(limit) = limit {
path.push_str(&format!("&limit={}", limit));
}
let response: crate::models::ApiListResponse<BibleVerse> = client.get_api_list_with_version(&path, ApiVersion::V2).await?;
Ok(response.data.items)
}

View file

@ -0,0 +1,101 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{Bulletin, NewBulletin, PaginationParams, ApiListResponse, ApiVersion},
};
pub async fn get_bulletins(client: &ChurchApiClient, active_only: bool) -> Result<Vec<Bulletin>> {
let path = if active_only {
"/bulletins?active=true"
} else {
"/bulletins"
};
let response: ApiListResponse<Bulletin> = client.get_api_list(path).await?;
Ok(response.data.items)
}
pub async fn get_current_bulletin(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
match client.get_api("/bulletins/current").await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn get_bulletin(client: &ChurchApiClient, id: &str) -> Result<Option<Bulletin>> {
let path = format!("/bulletins/{}", id);
match client.get_api(&path).await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn create_bulletin(client: &ChurchApiClient, bulletin: NewBulletin) -> Result<String> {
client.post_api("/bulletins", &bulletin).await
}
pub async fn get_next_bulletin(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
match client.get_api("/bulletins/next").await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
// V2 API methods
pub async fn get_bulletins_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Bulletin>> {
let mut path = "/bulletins".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
let url = client.build_url_with_version(&path, ApiVersion::V2);
let response: ApiListResponse<Bulletin> = client.get(&url).await?;
if response.success {
Ok(response)
} else {
Err(crate::error::ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub async fn get_current_bulletin_v2(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
match client.get_api_with_version("/bulletins/current", ApiVersion::V2).await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn get_next_bulletin_v2(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
match client.get_api_with_version("/bulletins/next", ApiVersion::V2).await {
Ok(bulletin) => Ok(Some(bulletin)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}

View file

@ -0,0 +1,50 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{ChurchConfig, Schedule, ConferenceData, ApiVersion},
};
pub async fn get_config(client: &ChurchApiClient) -> Result<ChurchConfig> {
client.get("/config").await
}
pub async fn get_config_by_id(client: &ChurchApiClient, record_id: &str) -> Result<ChurchConfig> {
let path = format!("/config/records/{}", record_id);
client.get_api(&path).await
}
pub async fn update_config(client: &ChurchApiClient, config: ChurchConfig) -> Result<()> {
client.put_api("/config", &config).await
}
// V2 API methods
pub async fn get_config_v2(client: &ChurchApiClient) -> Result<ChurchConfig> {
client.get_api_with_version("/config", ApiVersion::V2).await
}
// Schedule endpoints
pub async fn get_schedule(client: &ChurchApiClient, date: Option<&str>) -> Result<Schedule> {
let path = if let Some(date) = date {
format!("/schedule?date={}", date)
} else {
"/schedule".to_string()
};
client.get_api(&path).await
}
pub async fn get_schedule_v2(client: &ChurchApiClient, date: Option<&str>) -> Result<Schedule> {
let path = if let Some(date) = date {
format!("/schedule?date={}", date)
} else {
"/schedule".to_string()
};
client.get_api_with_version(&path, ApiVersion::V2).await
}
pub async fn get_conference_data(client: &ChurchApiClient) -> Result<ConferenceData> {
client.get_api("/conference-data").await
}
pub async fn get_conference_data_v2(client: &ChurchApiClient) -> Result<ConferenceData> {
client.get_api_with_version("/conference-data", ApiVersion::V2).await
}

View file

@ -0,0 +1,125 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{ContactForm, ContactSubmission, ContactStatus, PaginationParams, ApiListResponse, ApiVersion},
};
pub async fn submit_contact_form(client: &ChurchApiClient, form: ContactForm) -> Result<String> {
// Create payload matching the expected format from iOS app
let payload = serde_json::json!({
"first_name": form.name.split_whitespace().next().unwrap_or(&form.name),
"last_name": form.name.split_whitespace().nth(1).unwrap_or(""),
"email": form.email,
"phone": form.phone.unwrap_or_default(),
"message": form.message
});
// Use the main API subdomain for consistency
let contact_url = client.build_url("/contact");
let response = client.client
.post(contact_url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if response.status().is_success() {
Ok("Contact form submitted successfully".to_string())
} else {
Err(crate::error::ChurchApiError::Api(format!("Contact form submission failed with status: {}", response.status())))
}
}
pub async fn get_contact_submissions(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<ContactSubmission>> {
let mut path = "/contact/submissions".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if let Some(sort) = &params.sort {
query_params.push(("sort", sort.clone()));
}
if let Some(filter) = &params.filter {
query_params.push(("filter", filter.clone()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list(&path).await
}
pub async fn get_contact_submission(client: &ChurchApiClient, id: &str) -> Result<Option<ContactSubmission>> {
let path = format!("/contact/submissions/{}", id);
match client.get_api(&path).await {
Ok(submission) => Ok(Some(submission)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn update_contact_submission(
client: &ChurchApiClient,
id: &str,
status: ContactStatus,
response: Option<String>
) -> Result<()> {
let path = format!("/contact/submissions/{}", id);
let update_data = serde_json::json!({
"status": status,
"response": response
});
client.put_api(&path, &update_data).await
}
// V2 API methods
pub async fn submit_contact_form_v2(client: &ChurchApiClient, form: ContactForm) -> Result<String> {
let mut payload = serde_json::json!({
"name": form.name,
"email": form.email,
"subject": form.subject,
"message": form.message
});
// Add phone field if provided
if let Some(phone) = &form.phone {
if !phone.trim().is_empty() {
payload["phone"] = serde_json::json!(phone);
}
}
let url = client.build_url_with_version("/contact", ApiVersion::V2);
let response = client.client
.post(url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if response.status().is_success() {
Ok("Contact form submitted successfully".to_string())
} else {
Err(crate::error::ChurchApiError::Api(format!("Contact form submission failed with status: {}", response.status())))
}
}

View file

@ -0,0 +1,192 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{Event, NewEvent, EventUpdate, EventSubmission, PaginationParams, ApiListResponse, ApiVersion},
};
pub async fn get_events(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
let mut path = "/events".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if let Some(sort) = &params.sort {
query_params.push(("sort", sort.clone()));
}
if let Some(filter) = &params.filter {
query_params.push(("filter", filter.clone()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list(&path).await
}
pub async fn get_upcoming_events(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = "/events/upcoming".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api(&path).await
}
pub async fn get_event(client: &ChurchApiClient, id: &str) -> Result<Option<Event>> {
let path = format!("/events/{}", id);
match client.get_api(&path).await {
Ok(event) => Ok(Some(event)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn create_event(client: &ChurchApiClient, event: NewEvent) -> Result<String> {
client.post_api("/events", &event).await
}
pub async fn update_event(client: &ChurchApiClient, id: &str, update: EventUpdate) -> Result<()> {
let path = format!("/events/{}", id);
client.put_api(&path, &update).await
}
pub async fn delete_event(client: &ChurchApiClient, id: &str) -> Result<()> {
let path = format!("/events/{}", id);
client.delete_api(&path).await
}
pub async fn get_featured_events(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = "/events/featured".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api(&path).await
}
pub async fn get_events_by_category(client: &ChurchApiClient, category: &str, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = format!("/events/category/{}", category);
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api(&path).await
}
pub async fn get_events_by_date_range(
client: &ChurchApiClient,
start_date: &str,
end_date: &str
) -> Result<Vec<Event>> {
let path = format!("/events/range?start={}&end={}",
urlencoding::encode(start_date),
urlencoding::encode(end_date)
);
client.get_api(&path).await
}
pub async fn search_events(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = format!("/events/search?q={}", urlencoding::encode(query));
if let Some(limit) = limit {
path.push_str(&format!("&limit={}", limit));
}
client.get_api(&path).await
}
pub async fn upload_event_image(client: &ChurchApiClient, event_id: &str, image_data: Vec<u8>, filename: String) -> Result<String> {
let path = format!("/events/{}/image", event_id);
client.upload_file(&path, image_data, filename, "image".to_string()).await
}
// V2 API methods
pub async fn get_events_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
let mut path = "/events".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if let Some(sort) = &params.sort {
query_params.push(("sort", sort.clone()));
}
if let Some(filter) = &params.filter {
query_params.push(("filter", filter.clone()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list_with_version(&path, ApiVersion::V2).await
}
pub async fn get_upcoming_events_v2(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = "/events/upcoming".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api_with_version(&path, ApiVersion::V2).await
}
pub async fn get_featured_events_v2(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
let mut path = "/events/featured".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api_with_version(&path, ApiVersion::V2).await
}
pub async fn get_event_v2(client: &ChurchApiClient, id: &str) -> Result<Option<Event>> {
let path = format!("/events/{}", id);
match client.get_api_with_version(&path, ApiVersion::V2).await {
Ok(event) => Ok(Some(event)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn submit_event(client: &ChurchApiClient, submission: EventSubmission) -> Result<String> {
client.post_api("/events/submit", &submission).await
}

View file

@ -0,0 +1,402 @@
use crate::{
client::ChurchApiClient,
error::{ChurchApiError, Result},
models::{ApiResponse, ApiListResponse, ApiVersion},
cache::CachedHttpResponse,
};
use serde::{de::DeserializeOwned, Serialize};
use std::{collections::HashMap, time::Duration};
impl ChurchApiClient {
pub(crate) async fn get<T>(&self, path: &str) -> Result<T>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
self.get_with_version(path, ApiVersion::V1).await
}
pub(crate) async fn get_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<T>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
let cache_key = format!("GET:{}:{:?}", path, version);
// Check cache first
if self.config.enable_offline_mode {
if let Some(cached) = self.cache.get::<T>(&cache_key).await {
return Ok(cached);
}
}
let url = self.build_url_with_version(path, version);
let request = self.client.get(&url);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await?;
return Err(crate::error::ChurchApiError::Api(format!("HTTP {}: {}", status, error_text)));
}
let response_text = response.text().await?;
let data: T = serde_json::from_str(&response_text).map_err(|e| {
crate::error::ChurchApiError::Json(e)
})?;
// Cache the result
if self.config.enable_offline_mode {
self.cache.set(&cache_key, &data, self.config.cache_ttl).await;
}
Ok(data)
}
pub(crate) async fn get_api<T>(&self, path: &str) -> Result<T>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
self.get_api_with_version(path, ApiVersion::V1).await
}
pub(crate) async fn get_api_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<T>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
let response: ApiResponse<T> = self.get_with_version(path, version).await?;
if response.success {
response.data.ok_or_else(|| {
ChurchApiError::Api("API returned success but no data".to_string())
})
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub(crate) async fn get_api_list<T>(&self, path: &str) -> Result<ApiListResponse<T>>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
self.get_api_list_with_version(path, ApiVersion::V1).await
}
pub(crate) async fn get_api_list_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<ApiListResponse<T>>
where
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
{
let response: ApiListResponse<T> = self.get_with_version(path, version).await?;
if response.success {
Ok(response)
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub(crate) async fn post<T, R>(&self, path: &str, data: &T) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
self.post_with_version(path, data, ApiVersion::V1).await
}
pub(crate) async fn post_with_version<T, R>(&self, path: &str, data: &T, version: ApiVersion) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
let url = self.build_url_with_version(path, version);
let request = self.client.post(&url).json(data);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
let result: R = response.json().await?;
// Invalidate related cache entries
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
Ok(result)
}
pub(crate) async fn post_api<T, R>(&self, path: &str, data: &T) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
self.post_api_with_version(path, data, ApiVersion::V1).await
}
pub(crate) async fn post_api_with_version<T, R>(&self, path: &str, data: &T, version: ApiVersion) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
let response: ApiResponse<R> = self.post_with_version(path, data, version).await?;
if response.success {
response.data.ok_or_else(|| {
ChurchApiError::Api("API returned success but no data".to_string())
})
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub(crate) async fn put<T, R>(&self, path: &str, data: &T) -> Result<R>
where
T: Serialize,
R: DeserializeOwned,
{
let url = self.build_url(path);
let request = self.client.put(&url).json(data);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
let result: R = response.json().await?;
// Invalidate related cache entries
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
Ok(result)
}
pub(crate) async fn put_api<T>(&self, path: &str, data: &T) -> Result<()>
where
T: Serialize,
{
let response: ApiResponse<()> = self.put(path, data).await?;
if response.success {
Ok(())
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
pub(crate) async fn delete(&self, path: &str) -> Result<()> {
let url = self.build_url(path);
let request = self.client.delete(&url);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
if !response.status().is_success() {
return Err(ChurchApiError::Http(
reqwest::Error::from(response.error_for_status().unwrap_err())
));
}
// Invalidate related cache entries
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
Ok(())
}
pub(crate) async fn delete_api(&self, path: &str) -> Result<()> {
let response: ApiResponse<()> = {
let url = self.build_url(path);
let request = self.client.delete(&url);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
response.json().await?
};
if response.success {
Ok(())
} else {
Err(ChurchApiError::Api(
response.error
.or(response.message)
.unwrap_or_else(|| "Unknown API error".to_string())
))
}
}
async fn send_with_retry(&self, request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
let mut attempts = 0;
let max_attempts = self.config.retry_attempts;
loop {
attempts += 1;
// Clone the request for potential retry
let cloned_request = request.try_clone()
.ok_or_else(|| ChurchApiError::Internal("Failed to clone request".to_string()))?;
match cloned_request.send().await {
Ok(response) => {
let status = response.status();
if status.is_success() {
return Ok(response);
} else if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(ChurchApiError::Auth("Unauthorized".to_string()));
} else if status == reqwest::StatusCode::FORBIDDEN {
return Err(ChurchApiError::PermissionDenied);
} else if status == reqwest::StatusCode::NOT_FOUND {
return Err(ChurchApiError::NotFound);
} else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if attempts < max_attempts {
// Exponential backoff for rate limiting
let delay = std::time::Duration::from_millis(100 * 2_u64.pow(attempts - 1));
tokio::time::sleep(delay).await;
continue;
} else {
return Err(ChurchApiError::RateLimit);
}
} else if status.is_server_error() && attempts < max_attempts {
// Retry on server errors
let delay = std::time::Duration::from_millis(500 * attempts as u64);
tokio::time::sleep(delay).await;
continue;
} else {
return Err(ChurchApiError::Http(
reqwest::Error::from(response.error_for_status().unwrap_err())
));
}
}
Err(e) => {
if attempts < max_attempts && (e.is_timeout() || e.is_connect()) {
// Retry on timeout and connection errors
let delay = std::time::Duration::from_millis(500 * attempts as u64);
tokio::time::sleep(delay).await;
continue;
} else {
return Err(ChurchApiError::Http(e));
}
}
}
}
}
async fn invalidate_cache_prefix(&self, prefix: &str) {
self.cache.invalidate_prefix(prefix).await;
}
pub(crate) fn build_query_string(&self, params: &[(&str, &str)]) -> String {
if params.is_empty() {
return String::new();
}
let query: Vec<String> = params
.iter()
.map(|(key, value)| format!("{}={}", urlencoding::encode(key), urlencoding::encode(value)))
.collect();
format!("?{}", query.join("&"))
}
pub(crate) async fn upload_file(&self, path: &str, file_data: Vec<u8>, filename: String, field_name: String) -> Result<String> {
let url = self.build_url(path);
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename)
.mime_str("application/octet-stream")
.map_err(|e| ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
let form = reqwest::multipart::Form::new()
.part(field_name, part);
let request = self.client.post(&url).multipart(form);
let request = self.add_auth_header(request).await;
let response = self.send_with_retry(request).await?;
let result: ApiResponse<String> = response.json().await?;
if result.success {
result.data.ok_or_else(|| {
ChurchApiError::Api("File upload succeeded but no URL returned".to_string())
})
} else {
Err(ChurchApiError::Api(
result.error
.or(result.message)
.unwrap_or_else(|| "File upload failed".to_string())
))
}
}
/// Fetch an image with HTTP caching support
pub async fn get_cached_image(&self, url: &str) -> Result<CachedHttpResponse> {
// Check cache first
if let Some(cached) = self.cache.get_http_response(url).await {
println!("📸 Cache HIT for image: {}", url);
return Ok(cached);
}
println!("📸 Cache MISS for image: {}", url);
// Make HTTP request
let request = self.client.get(url);
let response = self.send_with_retry(request).await?;
let status = response.status();
let headers = response.headers().clone();
if !status.is_success() {
return Err(ChurchApiError::Http(
reqwest::Error::from(response.error_for_status().unwrap_err())
));
}
// Extract headers we care about
let mut header_map = HashMap::new();
for (name, value) in headers.iter() {
if let Ok(value_str) = value.to_str() {
header_map.insert(name.to_string(), value_str.to_string());
}
}
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
// Get response body
let data = response.bytes().await?.to_vec();
// Determine cache TTL based on content type
let ttl = if content_type.starts_with("image/") {
Duration::from_secs(24 * 60 * 60) // 24 hours for images
} else {
Duration::from_secs(5 * 60) // 5 minutes for other content
};
// Create cached response
let cached_response = CachedHttpResponse::new(
data,
content_type,
header_map,
status.as_u16(),
ttl,
);
// Store in cache
self.cache.set_http_response(url, cached_response.clone()).await;
Ok(cached_response)
}
}

View file

@ -0,0 +1,35 @@
use crate::{
client::ChurchApiClient,
error::Result,
};
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamStatus {
pub is_live: bool,
pub last_connect_time: Option<DateTime<Utc>>,
pub last_disconnect_time: Option<DateTime<Utc>>,
pub stream_title: Option<String>,
pub stream_url: Option<String>,
pub viewer_count: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveStream {
pub last_connect_time: Option<DateTime<Utc>>,
pub last_disconnect_time: Option<DateTime<Utc>>,
pub viewer_count: Option<u32>,
pub stream_title: Option<String>,
pub is_live: bool,
}
/// Get current stream status from Owncast
pub async fn get_stream_status(client: &ChurchApiClient) -> Result<StreamStatus> {
client.get("/stream/status").await
}
/// Get live stream info from Owncast
pub async fn get_live_stream(client: &ChurchApiClient) -> Result<LiveStream> {
client.get("/stream/live").await
}

View file

@ -0,0 +1,412 @@
pub mod http;
pub mod events;
pub mod bulletins;
pub mod config;
pub mod contact;
pub mod sermons;
pub mod bible;
pub mod admin;
pub mod uploads;
pub mod livestream;
use crate::{
cache::MemoryCache,
config::ChurchCoreConfig,
error::Result,
models::*,
};
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct ChurchApiClient {
pub(crate) client: reqwest::Client,
pub(crate) config: ChurchCoreConfig,
pub(crate) auth_token: Arc<RwLock<Option<AuthToken>>>,
pub(crate) cache: Arc<MemoryCache>,
}
impl ChurchApiClient {
pub fn new(config: ChurchCoreConfig) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(config.timeout)
.connect_timeout(config.connect_timeout)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.user_agent(&config.user_agent)
.build()?;
let cache = Arc::new(MemoryCache::new(config.max_cache_size));
Ok(Self {
client,
config,
auth_token: Arc::new(RwLock::new(None)),
cache,
})
}
pub fn with_cache(mut self, cache: Arc<MemoryCache>) -> Self {
self.cache = cache;
self
}
pub async fn set_auth_token(&self, token: AuthToken) {
let mut auth = self.auth_token.write().await;
*auth = Some(token);
}
pub async fn clear_auth_token(&self) {
let mut auth = self.auth_token.write().await;
*auth = None;
}
pub async fn get_auth_token(&self) -> Option<AuthToken> {
let auth = self.auth_token.read().await;
auth.clone()
}
pub async fn is_authenticated(&self) -> bool {
if let Some(token) = self.get_auth_token().await {
token.is_valid()
} else {
false
}
}
pub(crate) fn build_url(&self, path: &str) -> String {
self.build_url_with_version(path, crate::models::ApiVersion::V1)
}
pub(crate) fn build_url_with_version(&self, path: &str, version: crate::models::ApiVersion) -> String {
if path.starts_with("http") {
path.to_string()
} else {
let base = self.config.api_base_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
let version_prefix = version.path_prefix();
if base.ends_with("/api") {
format!("{}/{}{}", base, version_prefix, path)
} else {
format!("{}/api/{}{}", base, version_prefix, path)
}
}
}
pub(crate) async fn add_auth_header(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(token) = self.get_auth_token().await {
if token.is_valid() {
return builder.header("Authorization", format!("{} {}", token.token_type, token.token));
}
}
builder
}
// Event operations
pub async fn get_upcoming_events(&self, limit: Option<u32>) -> Result<Vec<Event>> {
events::get_upcoming_events(self, limit).await
}
pub async fn get_events(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
events::get_events(self, params).await
}
pub async fn get_event(&self, id: &str) -> Result<Option<Event>> {
events::get_event(self, id).await
}
pub async fn create_event(&self, event: NewEvent) -> Result<String> {
events::create_event(self, event).await
}
pub async fn update_event(&self, id: &str, update: EventUpdate) -> Result<()> {
events::update_event(self, id, update).await
}
pub async fn delete_event(&self, id: &str) -> Result<()> {
events::delete_event(self, id).await
}
// Bulletin operations
pub async fn get_bulletins(&self, active_only: bool) -> Result<Vec<Bulletin>> {
bulletins::get_bulletins(self, active_only).await
}
pub async fn get_current_bulletin(&self) -> Result<Option<Bulletin>> {
bulletins::get_current_bulletin(self).await
}
pub async fn get_next_bulletin(&self) -> Result<Option<Bulletin>> {
bulletins::get_next_bulletin(self).await
}
pub async fn get_bulletin(&self, id: &str) -> Result<Option<Bulletin>> {
bulletins::get_bulletin(self, id).await
}
pub async fn create_bulletin(&self, bulletin: NewBulletin) -> Result<String> {
bulletins::create_bulletin(self, bulletin).await
}
// V2 API methods
pub async fn get_bulletins_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Bulletin>> {
bulletins::get_bulletins_v2(self, params).await
}
pub async fn get_current_bulletin_v2(&self) -> Result<Option<Bulletin>> {
bulletins::get_current_bulletin_v2(self).await
}
pub async fn get_next_bulletin_v2(&self) -> Result<Option<Bulletin>> {
bulletins::get_next_bulletin_v2(self).await
}
// Configuration
pub async fn get_config(&self) -> Result<ChurchConfig> {
config::get_config(self).await
}
pub async fn get_config_by_id(&self, record_id: &str) -> Result<ChurchConfig> {
config::get_config_by_id(self, record_id).await
}
pub async fn update_config(&self, config: ChurchConfig) -> Result<()> {
config::update_config(self, config).await
}
// Contact operations
pub async fn submit_contact_form(&self, form: ContactForm) -> Result<String> {
contact::submit_contact_form(self, form).await
}
pub async fn get_contact_submissions(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<ContactSubmission>> {
contact::get_contact_submissions(self, params).await
}
pub async fn get_contact_submission(&self, id: &str) -> Result<Option<ContactSubmission>> {
contact::get_contact_submission(self, id).await
}
pub async fn update_contact_submission(&self, id: &str, status: ContactStatus, response: Option<String>) -> Result<()> {
contact::update_contact_submission(self, id, status, response).await
}
// Sermon operations
pub async fn get_sermons(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
sermons::get_sermons(self, params).await
}
pub async fn search_sermons(&self, search: SermonSearch, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
sermons::search_sermons(self, search, params).await
}
pub async fn get_sermon(&self, id: &str) -> Result<Option<Sermon>> {
sermons::get_sermon(self, id).await
}
pub async fn get_featured_sermons(&self, limit: Option<u32>) -> Result<Vec<Sermon>> {
sermons::get_featured_sermons(self, limit).await
}
pub async fn get_recent_sermons(&self, limit: Option<u32>) -> Result<Vec<Sermon>> {
sermons::get_recent_sermons(self, limit).await
}
pub async fn create_sermon(&self, sermon: NewSermon) -> Result<String> {
sermons::create_sermon(self, sermon).await
}
// Bible verse operations
pub async fn get_random_verse(&self) -> Result<BibleVerse> {
bible::get_random_verse(self).await
}
pub async fn get_verse_of_the_day(&self) -> Result<VerseOfTheDay> {
bible::get_verse_of_the_day(self).await
}
pub async fn get_verse_by_reference(&self, reference: &str) -> Result<Option<BibleVerse>> {
bible::get_verse_by_reference(self, reference).await
}
pub async fn get_verses_by_category(&self, category: VerseCategory, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
bible::get_verses_by_category(self, category, limit).await
}
pub async fn search_verses(&self, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
bible::search_verses(self, query, limit).await
}
// V2 API methods
// Events V2
pub async fn get_events_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
events::get_events_v2(self, params).await
}
pub async fn get_upcoming_events_v2(&self, limit: Option<u32>) -> Result<Vec<Event>> {
events::get_upcoming_events_v2(self, limit).await
}
pub async fn get_featured_events_v2(&self, limit: Option<u32>) -> Result<Vec<Event>> {
events::get_featured_events_v2(self, limit).await
}
pub async fn get_event_v2(&self, id: &str) -> Result<Option<Event>> {
events::get_event_v2(self, id).await
}
pub async fn submit_event(&self, submission: EventSubmission) -> Result<String> {
events::submit_event(self, submission).await
}
// Bible V2
pub async fn get_random_verse_v2(&self) -> Result<BibleVerse> {
bible::get_random_verse_v2(self).await
}
pub async fn get_bible_verses_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<BibleVerse>> {
bible::get_bible_verses_v2(self, params).await
}
pub async fn search_verses_v2(&self, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
bible::search_verses_v2(self, query, limit).await
}
// Contact V2
pub async fn submit_contact_form_v2(&self, form: ContactForm) -> Result<String> {
contact::submit_contact_form_v2(self, form).await
}
// Config and Schedule V2
pub async fn get_config_v2(&self) -> Result<ChurchConfig> {
config::get_config_v2(self).await
}
pub async fn get_schedule(&self, date: Option<&str>) -> Result<Schedule> {
config::get_schedule(self, date).await
}
pub async fn get_schedule_v2(&self, date: Option<&str>) -> Result<Schedule> {
config::get_schedule_v2(self, date).await
}
pub async fn get_conference_data(&self) -> Result<ConferenceData> {
config::get_conference_data(self).await
}
pub async fn get_conference_data_v2(&self) -> Result<ConferenceData> {
config::get_conference_data_v2(self).await
}
pub async fn get_livestreams(&self) -> Result<Vec<Sermon>> {
sermons::get_livestreams(self).await
}
// Owncast Live Streaming
pub async fn get_stream_status(&self) -> Result<livestream::StreamStatus> {
livestream::get_stream_status(self).await
}
pub async fn get_live_stream(&self) -> Result<livestream::LiveStream> {
livestream::get_live_stream(self).await
}
// Admin operations
// Admin Bulletins
pub async fn create_admin_bulletin(&self, bulletin: NewBulletin) -> Result<String> {
admin::create_bulletin(self, bulletin).await
}
pub async fn update_admin_bulletin(&self, id: &str, update: BulletinUpdate) -> Result<()> {
admin::update_bulletin(self, id, update).await
}
pub async fn delete_admin_bulletin(&self, id: &str) -> Result<()> {
admin::delete_bulletin(self, id).await
}
// Admin Events
pub async fn create_admin_event(&self, event: NewEvent) -> Result<String> {
admin::create_admin_event(self, event).await
}
pub async fn update_admin_event(&self, id: &str, update: EventUpdate) -> Result<()> {
admin::update_admin_event(self, id, update).await
}
pub async fn delete_admin_event(&self, id: &str) -> Result<()> {
admin::delete_admin_event(self, id).await
}
// Admin Pending Events
pub async fn get_pending_events(&self) -> Result<Vec<PendingEvent>> {
admin::get_pending_events(self).await
}
pub async fn approve_pending_event(&self, id: &str) -> Result<()> {
admin::approve_pending_event(self, id).await
}
pub async fn reject_pending_event(&self, id: &str) -> Result<()> {
admin::reject_pending_event(self, id).await
}
pub async fn delete_pending_event(&self, id: &str) -> Result<()> {
admin::delete_pending_event(self, id).await
}
// Admin Users
pub async fn get_admin_users(&self) -> Result<Vec<User>> {
admin::get_users(self).await
}
// Admin Schedule
pub async fn create_admin_schedule(&self, schedule: NewSchedule) -> Result<String> {
admin::create_schedule(self, schedule).await
}
pub async fn update_admin_schedule(&self, date: &str, update: ScheduleUpdate) -> Result<()> {
admin::update_schedule(self, date, update).await
}
pub async fn delete_admin_schedule(&self, date: &str) -> Result<()> {
admin::delete_schedule(self, date).await
}
pub async fn get_all_admin_schedules(&self) -> Result<Vec<Schedule>> {
admin::get_all_schedules(self).await
}
pub async fn get_admin_config(&self) -> Result<ChurchConfig> {
admin::get_admin_config(self).await
}
// File Upload operations
pub async fn upload_bulletin_pdf(&self, bulletin_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
uploads::upload_bulletin_pdf(self, bulletin_id, file_data, filename).await
}
pub async fn upload_bulletin_cover(&self, bulletin_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
uploads::upload_bulletin_cover(self, bulletin_id, file_data, filename).await
}
pub async fn upload_event_image(&self, event_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
uploads::upload_event_image(self, event_id, file_data, filename).await
}
// Utility methods
pub async fn health_check(&self) -> Result<bool> {
let url = self.build_url("/health");
let response = self.client.get(&url).send().await?;
Ok(response.status().is_success())
}
pub async fn clear_cache(&self) {
self.cache.clear().await;
}
pub async fn get_cache_stats(&self) -> (usize, usize) {
(self.cache.len().await, self.config.max_cache_size)
}
}

View file

@ -0,0 +1,237 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::{Sermon, ApiSermon, NewSermon, SermonSearch, PaginationParams, ApiListResponse, DeviceCapabilities},
};
pub async fn get_sermons(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
let mut path = "/sermons".to_string();
if let Some(params) = params {
let mut query_params = Vec::new();
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
if let Some(sort) = &params.sort {
query_params.push(("sort", sort.clone()));
}
if let Some(filter) = &params.filter {
query_params.push(("filter", filter.clone()));
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
}
client.get_api_list(&path).await
}
pub async fn search_sermons(
client: &ChurchApiClient,
search: SermonSearch,
params: Option<PaginationParams>
) -> Result<ApiListResponse<Sermon>> {
let mut path = "/sermons/search".to_string();
let mut query_params = Vec::new();
if let Some(query) = &search.query {
query_params.push(("q", query.clone()));
}
if let Some(speaker) = &search.speaker {
query_params.push(("speaker", speaker.clone()));
}
if let Some(category) = &search.category {
query_params.push(("category", format!("{:?}", category).to_lowercase()));
}
if let Some(series) = &search.series {
query_params.push(("series", series.clone()));
}
if let Some(featured_only) = search.featured_only {
if featured_only {
query_params.push(("featured", "true".to_string()));
}
}
if let Some(has_video) = search.has_video {
if has_video {
query_params.push(("has_video", "true".to_string()));
}
}
if let Some(has_audio) = search.has_audio {
if has_audio {
query_params.push(("has_audio", "true".to_string()));
}
}
if let Some(params) = params {
if let Some(page) = params.page {
query_params.push(("page", page.to_string()));
}
if let Some(per_page) = params.per_page {
query_params.push(("per_page", per_page.to_string()));
}
}
if !query_params.is_empty() {
let query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
path.push_str(&format!("?{}", query_string));
}
client.get_api_list(&path).await
}
pub async fn get_sermon(client: &ChurchApiClient, id: &str) -> Result<Option<Sermon>> {
let path = format!("/sermons/{}", id);
match client.get_api(&path).await {
Ok(sermon) => Ok(Some(sermon)),
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn get_featured_sermons(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Sermon>> {
let mut path = "/sermons/featured".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
client.get_api(&path).await
}
// Helper function to convert seconds to human readable duration
fn format_duration_seconds(seconds: u32) -> String {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let remaining_seconds = seconds % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, minutes, remaining_seconds)
} else {
format!("{}:{:02}", minutes, remaining_seconds)
}
}
// Shared function to convert API sermon/livestream data to Sermon model
fn convert_api_sermon_to_sermon(api_sermon: ApiSermon, category: crate::models::sermon::SermonCategory) -> Sermon {
// Parse date string to DateTime if available
let date = if let Some(date_str) = &api_sermon.date {
chrono::DateTime::parse_from_str(&format!("{} 00:00:00 +0000", date_str), "%Y-%m-%d %H:%M:%S %z")
.unwrap_or_else(|_| chrono::Utc::now().into())
.with_timezone(&chrono::Utc)
} else {
chrono::Utc::now()
};
// Duration is already in string format from the API, so use it directly
let duration_string = Some(api_sermon.duration.clone());
// Generate optimal streaming URL for the device
let media_url = if !api_sermon.id.is_empty() {
let base_url = "https://api.rockvilletollandsda.church"; // TODO: Get from config
let streaming_url = DeviceCapabilities::get_optimal_streaming_url(base_url, &api_sermon.id);
Some(streaming_url.url)
} else {
api_sermon.video_url.clone()
};
Sermon {
id: api_sermon.id.clone(),
title: api_sermon.title,
speaker: api_sermon.speaker.unwrap_or("Unknown".to_string()),
description: api_sermon.description.unwrap_or_default(),
date,
scripture_reference: api_sermon.scripture_reading.unwrap_or_default(),
series: None,
duration_string,
media_url,
audio_url: api_sermon.audio_url,
video_url: api_sermon.video_url,
transcript: None,
thumbnail: api_sermon.thumbnail,
tags: None,
category,
is_featured: false,
view_count: 0,
download_count: 0,
created_at: date,
updated_at: date,
}
}
pub async fn get_recent_sermons(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Sermon>> {
let mut path = "/sermons".to_string();
if let Some(limit) = limit {
path.push_str(&format!("?limit={}", limit));
}
// The new API returns a wrapper with "sermons" array
#[derive(serde::Deserialize, serde::Serialize)]
struct SermonsResponse {
success: bool,
data: Vec<ApiSermon>,
message: Option<String>,
}
let response: SermonsResponse = client.get(&path).await?;
// Convert using shared logic
let sermons = response.data.into_iter()
.map(|api_sermon| convert_api_sermon_to_sermon(api_sermon, crate::models::sermon::SermonCategory::Regular))
.collect();
Ok(sermons)
}
pub async fn create_sermon(client: &ChurchApiClient, sermon: NewSermon) -> Result<String> {
client.post_api("/sermons", &sermon).await
}
// Livestreams endpoint - reuses ApiSermon since format is identical
pub async fn get_livestreams(client: &ChurchApiClient) -> Result<Vec<Sermon>> {
// Use the new API endpoint for livestreams
let path = "/livestreams";
// The new API returns a wrapper with "data" array (same format as sermons endpoint)
#[derive(serde::Deserialize, serde::Serialize)]
struct LivestreamsResponse {
success: bool,
data: Vec<ApiSermon>,
message: Option<String>,
}
let response: LivestreamsResponse = client.get(path).await?;
// Convert using shared logic - same as regular sermons but different category
let sermons = response.data.into_iter()
.map(|api_sermon| convert_api_sermon_to_sermon(api_sermon, crate::models::sermon::SermonCategory::LivestreamArchive))
.collect();
Ok(sermons)
}

View file

@ -0,0 +1,119 @@
use crate::{
client::ChurchApiClient,
error::Result,
models::UploadResponse,
};
/// Upload PDF file for a bulletin
pub async fn upload_bulletin_pdf(
client: &ChurchApiClient,
bulletin_id: &str,
file_data: Vec<u8>,
filename: String,
) -> Result<UploadResponse> {
let path = format!("/upload/bulletins/{}/pdf", bulletin_id);
let url = client.build_url(&path);
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename)
.mime_str("application/pdf")
.map_err(|e| crate::error::ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
let form = reqwest::multipart::Form::new()
.part("file", part);
let request = client.client.post(&url).multipart(form);
let request = client.add_auth_header(request).await;
let response = request.send().await?;
let result: crate::models::ApiResponse<UploadResponse> = response.json().await?;
if result.success {
result.data.ok_or_else(|| {
crate::error::ChurchApiError::Api("File upload succeeded but no response returned".to_string())
})
} else {
Err(crate::error::ChurchApiError::Api(
result.error
.or(result.message)
.unwrap_or_else(|| "File upload failed".to_string())
))
}
}
/// Upload cover image for a bulletin
pub async fn upload_bulletin_cover(
client: &ChurchApiClient,
bulletin_id: &str,
file_data: Vec<u8>,
filename: String,
) -> Result<UploadResponse> {
let path = format!("/upload/bulletins/{}/cover", bulletin_id);
let url = client.build_url(&path);
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename)
.mime_str("image/jpeg")
.map_err(|e| crate::error::ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
let form = reqwest::multipart::Form::new()
.part("file", part);
let request = client.client.post(&url).multipart(form);
let request = client.add_auth_header(request).await;
let response = request.send().await?;
let result: crate::models::ApiResponse<UploadResponse> = response.json().await?;
if result.success {
result.data.ok_or_else(|| {
crate::error::ChurchApiError::Api("File upload succeeded but no response returned".to_string())
})
} else {
Err(crate::error::ChurchApiError::Api(
result.error
.or(result.message)
.unwrap_or_else(|| "File upload failed".to_string())
))
}
}
/// Upload image for an event
pub async fn upload_event_image(
client: &ChurchApiClient,
event_id: &str,
file_data: Vec<u8>,
filename: String,
) -> Result<UploadResponse> {
let path = format!("/upload/events/{}/image", event_id);
let url = client.build_url(&path);
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename)
.mime_str("image/jpeg")
.map_err(|e| crate::error::ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
let form = reqwest::multipart::Form::new()
.part("file", part);
let request = client.client.post(&url).multipart(form);
let request = client.add_auth_header(request).await;
let response = request.send().await?;
let result: crate::models::ApiResponse<UploadResponse> = response.json().await?;
if result.success {
result.data.ok_or_else(|| {
crate::error::ChurchApiError::Api("File upload succeeded but no response returned".to_string())
})
} else {
Err(crate::error::ChurchApiError::Api(
result.error
.or(result.message)
.unwrap_or_else(|| "File upload failed".to_string())
))
}
}

69
church-core/src/config.rs Normal file
View file

@ -0,0 +1,69 @@
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct ChurchCoreConfig {
pub api_base_url: String,
pub cache_ttl: Duration,
pub timeout: Duration,
pub connect_timeout: Duration,
pub retry_attempts: u32,
pub enable_offline_mode: bool,
pub max_cache_size: usize,
pub user_agent: String,
}
impl Default for ChurchCoreConfig {
fn default() -> Self {
Self {
api_base_url: "https://api.rockvilletollandsda.church".to_string(),
cache_ttl: Duration::from_secs(300), // 5 minutes
timeout: Duration::from_secs(10),
connect_timeout: Duration::from_secs(5),
retry_attempts: 3,
enable_offline_mode: true,
max_cache_size: 1000,
user_agent: format!("church-core/{}", env!("CARGO_PKG_VERSION")),
}
}
}
impl ChurchCoreConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.api_base_url = url.into();
self
}
pub fn with_cache_ttl(mut self, ttl: Duration) -> Self {
self.cache_ttl = ttl;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_retry_attempts(mut self, attempts: u32) -> Self {
self.retry_attempts = attempts;
self
}
pub fn with_offline_mode(mut self, enabled: bool) -> Self {
self.enable_offline_mode = enabled;
self
}
pub fn with_max_cache_size(mut self, size: usize) -> Self {
self.max_cache_size = size;
self
}
pub fn with_user_agent(mut self, agent: impl Into<String>) -> Self {
self.user_agent = agent.into();
self
}
}

62
church-core/src/error.rs Normal file
View file

@ -0,0 +1,62 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ChurchApiError>;
#[derive(Debug, Error)]
pub enum ChurchApiError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("JSON parsing failed: {0}")]
Json(#[from] serde_json::Error),
#[error("Date parsing failed: {0}")]
DateParse(String),
#[error("API returned error: {0}")]
Api(String),
#[error("Authentication failed: {0}")]
Auth(String),
#[error("Cache error: {0}")]
Cache(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("Network error: {0}")]
Network(String),
#[error("Timeout error: operation took too long")]
Timeout,
#[error("Rate limit exceeded")]
RateLimit,
#[error("Resource not found")]
NotFound,
#[error("Permission denied")]
PermissionDenied,
#[error("Internal error: {0}")]
Internal(String),
}
impl ChurchApiError {
pub fn is_network_error(&self) -> bool {
matches!(self, Self::Http(_) | Self::Network(_) | Self::Timeout)
}
pub fn is_auth_error(&self) -> bool {
matches!(self, Self::Auth(_) | Self::PermissionDenied)
}
pub fn is_temporary(&self) -> bool {
matches!(self, Self::Timeout | Self::RateLimit | Self::Network(_))
}
}

12
church-core/src/ffi.rs Normal file
View file

@ -0,0 +1,12 @@
// FFI module for church-core
// This module is only compiled when the ffi feature is enabled
use crate::{ChurchApiClient, ChurchCoreConfig, ChurchApiError};
// Re-export for UniFFI
pub use crate::{
models::*,
ChurchApiClient,
ChurchCoreConfig,
ChurchApiError,
};

24
church-core/src/lib.rs Normal file
View file

@ -0,0 +1,24 @@
pub mod client;
pub mod models;
pub mod auth;
pub mod cache;
pub mod utils;
pub mod error;
pub mod config;
pub use client::ChurchApiClient;
pub use config::ChurchCoreConfig;
pub use error::{ChurchApiError, Result};
pub use models::*;
pub use cache::*;
#[cfg(feature = "wasm")]
pub mod wasm;
#[cfg(feature = "uniffi")]
pub mod uniffi_wrapper;
#[cfg(feature = "uniffi")]
pub use uniffi_wrapper::*;
#[cfg(feature = "uniffi")]
uniffi::include_scaffolding!("church_core");

View file

@ -0,0 +1,92 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// User information for admin user management
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct User {
pub id: String,
pub username: String,
pub email: Option<String>,
pub role: AdminUserRole,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum AdminUserRole {
#[serde(rename = "admin")]
Admin,
#[serde(rename = "moderator")]
Moderator,
#[serde(rename = "user")]
User,
}
/// Schedule data
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Schedule {
pub date: String, // YYYY-MM-DD format
pub sabbath_school: Option<String>,
pub divine_worship: Option<String>,
pub scripture_reading: Option<String>,
pub sunset: Option<String>,
pub special_notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Conference data
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConferenceData {
pub id: String,
pub name: String,
pub website: Option<String>,
pub contact_info: Option<String>,
pub leadership: Option<Vec<ConferenceLeader>>,
pub announcements: Option<Vec<String>>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConferenceLeader {
pub name: String,
pub title: String,
pub email: Option<String>,
pub phone: Option<String>,
}
/// New schedule creation
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewSchedule {
pub date: String, // YYYY-MM-DD format
pub sabbath_school: Option<String>,
pub divine_worship: Option<String>,
pub scripture_reading: Option<String>,
pub sunset: Option<String>,
pub special_notes: Option<String>,
}
/// Schedule update
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ScheduleUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub sabbath_school: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub divine_worship: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripture_reading: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sunset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_notes: Option<String>,
}
/// File upload response
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UploadResponse {
pub file_path: String,
pub pdf_path: Option<String>, // Full URL to the uploaded file
pub message: String,
}

View file

@ -0,0 +1,276 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthToken {
pub token: String,
pub token_type: String,
pub expires_at: DateTime<Utc>,
pub user_id: Option<String>,
pub user_name: Option<String>,
pub user_email: Option<String>,
pub permissions: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LoginRequest {
pub identity: String, // email or username
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LoginResponse {
pub token: String,
pub user: AuthUser,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthUser {
pub id: String,
pub email: String,
pub name: String,
pub username: Option<String>,
pub avatar: Option<String>,
pub verified: bool,
pub role: UserRole,
pub permissions: Vec<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RefreshTokenRequest {
pub refresh_token: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RefreshTokenResponse {
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PasswordResetRequest {
pub email: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PasswordResetConfirm {
pub token: String,
pub new_password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub name: String,
pub username: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EmailVerificationRequest {
pub token: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum UserRole {
#[serde(rename = "admin")]
Admin,
#[serde(rename = "pastor")]
Pastor,
#[serde(rename = "elder")]
Elder,
#[serde(rename = "deacon")]
Deacon,
#[serde(rename = "ministry_leader")]
MinistryLeader,
#[serde(rename = "member")]
Member,
#[serde(rename = "visitor")]
Visitor,
#[serde(rename = "guest")]
Guest,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PocketBaseAuthResponse {
pub token: String,
pub record: PocketBaseUser,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PocketBaseUser {
pub id: String,
pub email: String,
pub name: String,
pub username: Option<String>,
pub avatar: Option<String>,
pub verified: bool,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
}
impl AuthToken {
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn is_valid(&self) -> bool {
!self.is_expired() && !self.token.is_empty()
}
pub fn expires_in_seconds(&self) -> i64 {
(self.expires_at - Utc::now()).num_seconds().max(0)
}
pub fn expires_in_minutes(&self) -> i64 {
(self.expires_at - Utc::now()).num_minutes().max(0)
}
pub fn has_permission(&self, permission: &str) -> bool {
self.permissions.contains(&permission.to_string())
}
pub fn has_any_permission(&self, permissions: &[&str]) -> bool {
permissions.iter().any(|p| self.has_permission(p))
}
pub fn has_all_permissions(&self, permissions: &[&str]) -> bool {
permissions.iter().all(|p| self.has_permission(p))
}
}
impl AuthUser {
pub fn is_admin(&self) -> bool {
matches!(self.role, UserRole::Admin)
}
pub fn is_pastor(&self) -> bool {
matches!(self.role, UserRole::Pastor)
}
pub fn is_leadership(&self) -> bool {
matches!(
self.role,
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::Deacon
)
}
pub fn is_member(&self) -> bool {
matches!(
self.role,
UserRole::Admin
| UserRole::Pastor
| UserRole::Elder
| UserRole::Deacon
| UserRole::MinistryLeader
| UserRole::Member
)
}
pub fn can_edit_content(&self) -> bool {
matches!(
self.role,
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::MinistryLeader
)
}
pub fn can_moderate(&self) -> bool {
matches!(
self.role,
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::Deacon
)
}
pub fn display_name(&self) -> String {
if !self.name.is_empty() {
self.name.clone()
} else if let Some(username) = &self.username {
username.clone()
} else {
self.email.clone()
}
}
}
impl UserRole {
pub fn display_name(&self) -> &'static str {
match self {
UserRole::Admin => "Administrator",
UserRole::Pastor => "Pastor",
UserRole::Elder => "Elder",
UserRole::Deacon => "Deacon",
UserRole::MinistryLeader => "Ministry Leader",
UserRole::Member => "Member",
UserRole::Visitor => "Visitor",
UserRole::Guest => "Guest",
}
}
pub fn permissions(&self) -> Vec<&'static str> {
match self {
UserRole::Admin => vec![
"admin.*",
"events.*",
"bulletins.*",
"sermons.*",
"contacts.*",
"users.*",
"config.*",
],
UserRole::Pastor => vec![
"events.*",
"bulletins.*",
"sermons.*",
"contacts.read",
"contacts.respond",
"users.read",
],
UserRole::Elder => vec![
"events.read",
"events.create",
"bulletins.read",
"sermons.read",
"contacts.read",
"contacts.respond",
],
UserRole::Deacon => vec![
"events.read",
"bulletins.read",
"sermons.read",
"contacts.read",
],
UserRole::MinistryLeader => vec![
"events.read",
"events.create",
"bulletins.read",
"sermons.read",
],
UserRole::Member => vec![
"events.read",
"bulletins.read",
"sermons.read",
],
UserRole::Visitor => vec![
"events.read",
"bulletins.read",
"sermons.read",
],
UserRole::Guest => vec![
"events.read",
"bulletins.read",
],
}
}
}

View file

@ -0,0 +1,136 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BibleVerse {
pub text: String,
pub reference: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub book: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chapter: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verse: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<VerseCategory>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VerseOfTheDay {
pub verse: BibleVerse,
pub date: chrono::NaiveDate,
#[serde(skip_serializing_if = "Option::is_none")]
pub commentary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum VerseCategory {
#[serde(rename = "comfort")]
Comfort,
#[serde(rename = "hope")]
Hope,
#[serde(rename = "faith")]
Faith,
#[serde(rename = "love")]
Love,
#[serde(rename = "peace")]
Peace,
#[serde(rename = "strength")]
Strength,
#[serde(rename = "wisdom")]
Wisdom,
#[serde(rename = "guidance")]
Guidance,
#[serde(rename = "forgiveness")]
Forgiveness,
#[serde(rename = "salvation")]
Salvation,
#[serde(rename = "prayer")]
Prayer,
#[serde(rename = "praise")]
Praise,
#[serde(rename = "thanksgiving")]
Thanksgiving,
#[serde(rename = "other")]
Other,
}
impl BibleVerse {
pub fn new(text: String, reference: String) -> Self {
Self {
text,
reference,
version: None,
book: None,
chapter: None,
verse: None,
category: None,
}
}
pub fn with_version(mut self, version: String) -> Self {
self.version = Some(version);
self
}
pub fn with_book(mut self, book: String) -> Self {
self.book = Some(book);
self
}
pub fn with_location(mut self, chapter: u32, verse: u32) -> Self {
self.chapter = Some(chapter);
self.verse = Some(verse);
self
}
pub fn with_category(mut self, category: VerseCategory) -> Self {
self.category = Some(category);
self
}
}
impl VerseOfTheDay {
pub fn new(verse: BibleVerse, date: chrono::NaiveDate) -> Self {
Self {
verse,
date,
commentary: None,
theme: None,
}
}
pub fn with_commentary(mut self, commentary: String) -> Self {
self.commentary = Some(commentary);
self
}
pub fn with_theme(mut self, theme: String) -> Self {
self.theme = Some(theme);
self
}
}
impl VerseCategory {
pub fn display_name(&self) -> &'static str {
match self {
VerseCategory::Comfort => "Comfort",
VerseCategory::Hope => "Hope",
VerseCategory::Faith => "Faith",
VerseCategory::Love => "Love",
VerseCategory::Peace => "Peace",
VerseCategory::Strength => "Strength",
VerseCategory::Wisdom => "Wisdom",
VerseCategory::Guidance => "Guidance",
VerseCategory::Forgiveness => "Forgiveness",
VerseCategory::Salvation => "Salvation",
VerseCategory::Prayer => "Prayer",
VerseCategory::Praise => "Praise",
VerseCategory::Thanksgiving => "Thanksgiving",
VerseCategory::Other => "Other",
}
}
}

View file

@ -0,0 +1,240 @@
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Bulletin {
pub id: String,
pub title: String,
pub date: NaiveDate,
pub sabbath_school: String,
pub divine_worship: String,
pub scripture_reading: String,
pub sunset: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pdf_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
#[serde(default)]
pub is_active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub announcements: Option<Vec<Announcement>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymns: Option<Vec<BulletinHymn>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_music: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offering_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sermon_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub liturgy: Option<Vec<LiturgyItem>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewBulletin {
pub title: String,
pub date: NaiveDate,
pub sabbath_school: String,
pub divine_worship: String,
pub scripture_reading: String,
pub sunset: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pdf_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
#[serde(default)]
pub is_active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub announcements: Option<Vec<Announcement>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymns: Option<Vec<BulletinHymn>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_music: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offering_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sermon_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub liturgy: Option<Vec<LiturgyItem>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BulletinUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<NaiveDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sabbath_school: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub divine_worship: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripture_reading: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sunset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pdf_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_active: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub announcements: Option<Vec<Announcement>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymns: Option<Vec<BulletinHymn>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_music: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offering_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sermon_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub liturgy: Option<Vec<LiturgyItem>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Announcement {
pub id: Option<String>,
pub title: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<AnnouncementCategory>,
#[serde(default)]
pub is_urgent: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_info: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BulletinHymn {
pub number: u32,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<HymnCategory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verses: Option<Vec<u32>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LiturgyItem {
pub order: u32,
pub item_type: LiturgyType,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub leader: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripture_reference: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymn_number: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum AnnouncementCategory {
#[serde(rename = "general")]
General,
#[serde(rename = "ministry")]
Ministry,
#[serde(rename = "social")]
Social,
#[serde(rename = "urgent")]
Urgent,
#[serde(rename = "prayer")]
Prayer,
#[serde(rename = "community")]
Community,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum HymnCategory {
#[serde(rename = "opening")]
Opening,
#[serde(rename = "closing")]
Closing,
#[serde(rename = "offertory")]
Offertory,
#[serde(rename = "communion")]
Communion,
#[serde(rename = "special")]
Special,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum LiturgyType {
#[serde(rename = "prelude")]
Prelude,
#[serde(rename = "welcome")]
Welcome,
#[serde(rename = "opening_hymn")]
OpeningHymn,
#[serde(rename = "prayer")]
Prayer,
#[serde(rename = "scripture")]
Scripture,
#[serde(rename = "children_story")]
ChildrenStory,
#[serde(rename = "hymn")]
Hymn,
#[serde(rename = "offertory")]
Offertory,
#[serde(rename = "sermon")]
Sermon,
#[serde(rename = "closing_hymn")]
ClosingHymn,
#[serde(rename = "benediction")]
Benediction,
#[serde(rename = "postlude")]
Postlude,
#[serde(rename = "announcements")]
Announcements,
#[serde(rename = "special_music")]
SpecialMusic,
}
impl Bulletin {
pub fn has_pdf(&self) -> bool {
self.pdf_path.is_some()
}
pub fn has_cover_image(&self) -> bool {
self.cover_image.is_some()
}
pub fn active_announcements(&self) -> Vec<&Announcement> {
self.announcements
.as_ref()
.map(|announcements| {
announcements
.iter()
.filter(|announcement| {
announcement.expires_at
.map_or(true, |expires| expires > Utc::now())
})
.collect()
})
.unwrap_or_default()
}
pub fn urgent_announcements(&self) -> Vec<&Announcement> {
self.announcements
.as_ref()
.map(|announcements| {
announcements
.iter()
.filter(|announcement| announcement.is_urgent)
.collect()
})
.unwrap_or_default()
}
}

View file

@ -0,0 +1,344 @@
use serde::{Deserialize, Serialize};
use crate::models::event::{Event, RecurringType};
use crate::models::bulletin::Bulletin;
use crate::models::sermon::Sermon;
use chrono::{DateTime, Utc, Local, Timelike};
/// Client-facing Event model with both raw timestamps and formatted display strings
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClientEvent {
pub id: String,
pub title: String,
pub description: String,
// Raw ISO timestamps for calendar/system APIs
#[serde(rename = "start_time")]
pub start_time: String, // ISO timestamp like "2025-08-13T05:00:00-04:00"
#[serde(rename = "end_time")]
pub end_time: String, // ISO timestamp like "2025-08-13T06:00:00-04:00"
// Formatted display strings for UI
#[serde(rename = "formatted_time")]
pub formatted_time: String, // "6:00 PM - 8:00 PM"
#[serde(rename = "formatted_date")]
pub formatted_date: String, // "Friday, August 15, 2025"
#[serde(rename = "formatted_date_time")]
pub formatted_date_time: String, // "Friday, August 15, 2025 at 6:00 PM"
// Additional display fields for UI components
#[serde(rename = "day_of_month")]
pub day_of_month: String, // "15"
#[serde(rename = "month_abbreviation")]
pub month_abbreviation: String, // "AUG"
#[serde(rename = "time_string")]
pub time_string: String, // "6:00 PM - 8:00 PM" (alias for formatted_time)
#[serde(rename = "is_multi_day")]
pub is_multi_day: bool, // true if event spans multiple days
#[serde(rename = "detailed_time_display")]
pub detailed_time_display: String, // Full time range for detail views
pub location: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "location_url")]
pub location_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
pub category: String,
#[serde(rename = "is_featured")]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none", rename = "recurring_type")]
pub recurring_type: Option<String>,
#[serde(rename = "created_at")]
pub created_at: String, // ISO timestamp
#[serde(rename = "updated_at")]
pub updated_at: String, // ISO timestamp
}
/// Helper function to format time range from DateTime objects in local timezone
fn format_time_range_from_datetime(start_time: &DateTime<Utc>, end_time: &DateTime<Utc>) -> String {
// Convert UTC to local timezone for display
let start_local = start_time.with_timezone(&Local);
let end_local = end_time.with_timezone(&Local);
// Use consistent formatting: always show hour without leading zero, include minutes, use PM/AM
let start_formatted = if start_local.minute() == 0 {
start_local.format("%l %p").to_string().trim().to_string()
} else {
start_local.format("%l:%M %p").to_string().trim().to_string()
};
let end_formatted = if end_local.minute() == 0 {
end_local.format("%l %p").to_string().trim().to_string()
} else {
end_local.format("%l:%M %p").to_string().trim().to_string()
};
// If start and end times are the same, just show one time
if start_formatted == end_formatted {
start_formatted
} else {
format!("{} - {}", start_formatted, end_formatted)
}
}
impl From<Event> for ClientEvent {
fn from(event: Event) -> Self {
let description = event.clean_description();
let category = event.category.to_string();
let recurring_type = event.recurring_type.as_ref().map(|rt| rt.to_string());
// Raw ISO timestamps for calendar/system APIs
let start_time = event.start_time.format("%Y-%m-%dT%H:%M:%S%z").to_string();
let end_time = event.end_time.format("%Y-%m-%dT%H:%M:%S%z").to_string();
// Generate formatted display strings in local timezone
let start_local = event.start_time.with_timezone(&Local);
let end_local = event.end_time.with_timezone(&Local);
// Check if event spans multiple days
let is_multi_day = start_local.date_naive() != end_local.date_naive();
let (formatted_time, formatted_date, formatted_date_time, day_of_month, month_abbreviation, time_string, detailed_time_display) = if is_multi_day {
// Multi-day event: show date range for formatted_date, but start time for simplified views
let start_date = start_local.format("%B %d, %Y").to_string();
let end_date = end_local.format("%B %d, %Y").to_string();
let formatted_date = format!("{} - {}", start_date, end_date);
// For detailed view: show full date range with full time range
let time_range = format_time_range_from_datetime(&event.start_time, &event.end_time);
let formatted_time = format!("{} - {}, {}",
start_local.format("%b %d").to_string(),
end_local.format("%b %d").to_string(),
time_range
);
// For HomeFeed simplified view: just show start time
let start_time_formatted = if start_local.minute() == 0 {
start_local.format("%l %p").to_string().trim().to_string()
} else {
start_local.format("%l:%M %p").to_string().trim().to_string()
};
let time_string = start_time_formatted;
// For detail views: use the same time_range that eliminates redundancy
let detailed_time_display = time_range.clone();
let formatted_date_time = format!("{} - {}", start_date, end_date);
// Use start date for calendar display
let day_of_month = start_local.format("%d").to_string().trim_start_matches('0').to_string();
let month_abbreviation = start_local.format("%b").to_string().to_uppercase();
(formatted_time, formatted_date, formatted_date_time, day_of_month, month_abbreviation, time_string, detailed_time_display)
} else {
// Single day event: show time range
let formatted_time = format_time_range_from_datetime(&event.start_time, &event.end_time);
let formatted_date = start_local.format("%B %d, %Y").to_string();
// Use consistent time formatting for single events too
let time_formatted = if start_local.minute() == 0 {
start_local.format("%l %p").to_string().trim().to_string()
} else {
start_local.format("%l:%M %p").to_string().trim().to_string()
};
let formatted_date_time = format!("{} at {}", formatted_date, time_formatted);
let day_of_month = start_local.format("%d").to_string().trim_start_matches('0').to_string();
let month_abbreviation = start_local.format("%b").to_string().to_uppercase();
// For single events, time_string should just be start time for HomeFeed
let time_string = time_formatted;
// For single events, detailed_time_display is same as formatted_time
let detailed_time_display = formatted_time.clone();
(formatted_time, formatted_date, formatted_date_time, day_of_month, month_abbreviation, time_string, detailed_time_display)
};
let created_at = event.created_at.format("%Y-%m-%dT%H:%M:%S%z").to_string();
let updated_at = event.updated_at.format("%Y-%m-%dT%H:%M:%S%z").to_string();
Self {
id: event.id,
title: event.title,
description,
start_time,
end_time,
formatted_time,
formatted_date,
formatted_date_time,
day_of_month,
month_abbreviation,
time_string,
is_multi_day,
detailed_time_display,
location: event.location,
location_url: event.location_url,
image: event.image,
thumbnail: event.thumbnail,
category,
is_featured: event.is_featured,
recurring_type,
created_at,
updated_at,
}
}
}
/// Client-facing Bulletin model with formatted dates
/// Serializes to camelCase JSON for iOS compatibility
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClientBulletin {
pub id: String,
pub title: String,
pub date: String, // Pre-formatted date string
#[serde(rename = "sabbathSchool")]
pub sabbath_school: String,
#[serde(rename = "divineWorship")]
pub divine_worship: String,
#[serde(rename = "scriptureReading")]
pub scripture_reading: String,
pub sunset: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "pdfPath")]
pub pdf_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "coverImage")]
pub cover_image: Option<String>,
#[serde(rename = "isActive")]
pub is_active: bool,
// Add other fields as needed
}
impl From<Bulletin> for ClientBulletin {
fn from(bulletin: Bulletin) -> Self {
Self {
id: bulletin.id,
title: bulletin.title,
date: bulletin.date.format("%A, %B %d, %Y").to_string(), // Format NaiveDate to string
sabbath_school: bulletin.sabbath_school,
divine_worship: bulletin.divine_worship,
scripture_reading: bulletin.scripture_reading,
sunset: bulletin.sunset,
pdf_path: bulletin.pdf_path,
cover_image: bulletin.cover_image,
is_active: bulletin.is_active,
}
}
}
/// Client-facing Sermon model with pre-formatted dates and cleaned data
/// Serializes to camelCase JSON for iOS compatibility
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClientSermon {
pub id: String,
pub title: String,
pub speaker: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>, // Pre-formatted date string
#[serde(skip_serializing_if = "Option::is_none", rename = "audioUrl")]
pub audio_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "videoUrl")]
pub video_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>, // Pre-formatted duration
#[serde(skip_serializing_if = "Option::is_none", rename = "mediaType")]
pub media_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "scriptureReading")]
pub scripture_reading: Option<String>,
}
impl ClientSermon {
/// Create a ClientSermon from a Sermon with URL conversion using base API URL
pub fn from_sermon_with_base_url(sermon: Sermon, base_url: &str) -> Self {
let date = sermon.date.format("%B %d, %Y").to_string();
let media_type = if sermon.has_video() {
Some("Video".to_string())
} else if sermon.has_audio() {
Some("Audio".to_string())
} else {
None
};
// Helper function to convert relative URLs to full URLs
let make_full_url = |url: Option<String>| -> Option<String> {
url.map(|u| {
if u.starts_with("http://") || u.starts_with("https://") {
// Already a full URL
u
} else if u.starts_with("/") {
// Relative URL starting with /
let base = base_url.trim_end_matches('/');
format!("{}{}", base, u)
} else {
// Relative URL not starting with /
let base = base_url.trim_end_matches('/');
format!("{}/{}", base, u)
}
})
};
Self {
id: sermon.id,
title: sermon.title,
speaker: sermon.speaker,
description: Some(sermon.description),
date: Some(date),
audio_url: make_full_url(sermon.audio_url),
video_url: make_full_url(sermon.video_url),
duration: sermon.duration_string, // Use raw duration string from API
media_type,
thumbnail: make_full_url(sermon.thumbnail),
image: None, // Sermons don't have separate image field
scripture_reading: if sermon.scripture_reference.is_empty() { None } else { Some(sermon.scripture_reference.clone()) },
}
}
}
impl From<Sermon> for ClientSermon {
fn from(sermon: Sermon) -> Self {
let date = sermon.date.format("%B %d, %Y").to_string();
let media_type = if sermon.has_video() {
Some("Video".to_string())
} else if sermon.has_audio() {
Some("Audio".to_string())
} else {
None
};
Self {
id: sermon.id,
title: sermon.title,
speaker: sermon.speaker,
description: Some(sermon.description),
date: Some(date),
audio_url: sermon.audio_url,
video_url: sermon.video_url,
duration: sermon.duration_string, // Use raw duration string from API
media_type,
thumbnail: sermon.thumbnail,
image: None, // Sermons don't have separate image field
scripture_reading: if sermon.scripture_reference.is_empty() { None } else { Some(sermon.scripture_reference.clone()) },
}
}
}
// Add ToString implementations for enums if not already present
impl ToString for RecurringType {
fn to_string(&self) -> String {
match self {
RecurringType::Daily => "Daily".to_string(),
RecurringType::Weekly => "Weekly".to_string(),
RecurringType::Biweekly => "Bi-weekly".to_string(),
RecurringType::Monthly => "Monthly".to_string(),
RecurringType::FirstTuesday => "First Tuesday".to_string(),
RecurringType::FirstSabbath => "First Sabbath".to_string(),
RecurringType::LastSabbath => "Last Sabbath".to_string(),
RecurringType::SecondThirdSaturday => "2nd/3rd Saturday Monthly".to_string(),
}
}
}

View file

@ -0,0 +1,73 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiListResponse<T> {
pub success: bool,
pub data: ApiListData<T>,
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiListData<T> {
pub items: Vec<T>,
pub total: u32,
pub page: u32,
pub per_page: u32,
pub has_more: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PaginationParams {
pub page: Option<u32>,
pub per_page: Option<u32>,
pub sort: Option<String>,
pub filter: Option<String>,
}
impl Default for PaginationParams {
fn default() -> Self {
Self {
page: Some(1),
per_page: Some(50),
sort: None,
filter: None,
}
}
}
impl PaginationParams {
pub fn new() -> Self {
Self::default()
}
pub fn with_page(mut self, page: u32) -> Self {
self.page = Some(page);
self
}
pub fn with_per_page(mut self, per_page: u32) -> Self {
self.per_page = Some(per_page);
self
}
pub fn with_sort(mut self, sort: impl Into<String>) -> Self {
self.sort = Some(sort.into());
self
}
pub fn with_filter(mut self, filter: impl Into<String>) -> Self {
self.filter = Some(filter.into());
self
}
}

View file

@ -0,0 +1,253 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Coordinates {
pub lat: f64,
pub lng: f64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChurchConfig {
pub church_name: Option<String>,
pub church_address: Option<String>,
pub po_box: Option<String>,
pub contact_phone: Option<String>,
pub contact_email: Option<String>,
pub website_url: Option<String>,
pub google_maps_url: Option<String>,
pub facebook_url: Option<String>,
pub youtube_url: Option<String>,
pub instagram_url: Option<String>,
pub about_text: Option<String>,
pub mission_statement: Option<String>,
pub tagline: Option<String>,
pub brand_color: Option<String>,
pub donation_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_times: Option<Vec<ServiceTime>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pastoral_staff: Option<Vec<StaffMember>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ministries: Option<Vec<Ministry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_settings: Option<AppSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub emergency_contacts: Option<Vec<EmergencyContact>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coordinates: Option<Coordinates>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServiceTime {
pub day: String,
pub service: String,
pub time: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServiceTimes {
pub sabbath_school: Option<String>,
pub divine_worship: Option<String>,
pub prayer_meeting: Option<String>,
pub youth_service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub special_services: Option<Vec<SpecialService>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SpecialService {
pub name: String,
pub time: String,
pub frequency: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StaffMember {
pub name: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub photo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub responsibilities: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Ministry {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub leader: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meeting_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meeting_location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
pub category: MinistryCategory,
#[serde(default)]
pub is_active: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EmergencyContact {
pub name: String,
pub title: String,
pub phone: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
pub priority: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub availability: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppSettings {
pub enable_notifications: bool,
pub enable_calendar_sync: bool,
pub enable_offline_mode: bool,
pub theme: AppTheme,
pub default_language: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub owncast_server: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bible_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hymnal_version: Option<String>,
pub cache_duration_minutes: u32,
pub auto_refresh_interval_minutes: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum MinistryCategory {
#[serde(rename = "worship")]
Worship,
#[serde(rename = "education")]
Education,
#[serde(rename = "youth")]
Youth,
#[serde(rename = "children")]
Children,
#[serde(rename = "outreach")]
Outreach,
#[serde(rename = "health")]
Health,
#[serde(rename = "music")]
Music,
#[serde(rename = "fellowship")]
Fellowship,
#[serde(rename = "prayer")]
Prayer,
#[serde(rename = "stewardship")]
Stewardship,
#[serde(rename = "other")]
Other,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum AppTheme {
#[serde(rename = "light")]
Light,
#[serde(rename = "dark")]
Dark,
#[serde(rename = "system")]
System,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
enable_notifications: true,
enable_calendar_sync: true,
enable_offline_mode: true,
theme: AppTheme::System,
default_language: "en".to_string(),
owncast_server: None,
bible_version: Some("KJV".to_string()),
hymnal_version: Some("1985".to_string()),
cache_duration_minutes: 60,
auto_refresh_interval_minutes: 15,
}
}
}
impl ChurchConfig {
pub fn get_display_name(&self) -> String {
self.church_name
.as_ref()
.cloned()
.unwrap_or_else(|| "Church".to_string())
}
pub fn has_social_media(&self) -> bool {
self.facebook_url.is_some() || self.youtube_url.is_some() || self.instagram_url.is_some()
}
pub fn get_contact_info(&self) -> Vec<(String, String)> {
let mut contacts = Vec::new();
if let Some(phone) = &self.contact_phone {
contacts.push(("Phone".to_string(), phone.clone()));
}
if let Some(email) = &self.contact_email {
contacts.push(("Email".to_string(), email.clone()));
}
if let Some(address) = &self.church_address {
contacts.push(("Address".to_string(), address.clone()));
}
if let Some(po_box) = &self.po_box {
contacts.push(("PO Box".to_string(), po_box.clone()));
}
contacts
}
pub fn active_ministries(&self) -> Vec<&Ministry> {
self.ministries
.as_ref()
.map(|ministries| {
ministries
.iter()
.filter(|ministry| ministry.is_active)
.collect()
})
.unwrap_or_default()
}
pub fn ministries_by_category(&self, category: MinistryCategory) -> Vec<&Ministry> {
self.ministries
.as_ref()
.map(|ministries| {
ministries
.iter()
.filter(|ministry| ministry.category == category && ministry.is_active)
.collect()
})
.unwrap_or_default()
}
pub fn emergency_contacts_by_priority(&self) -> Vec<&EmergencyContact> {
self.emergency_contacts
.as_ref()
.map(|contacts| {
let mut sorted = contacts.iter().collect::<Vec<_>>();
sorted.sort_by_key(|contact| contact.priority);
sorted
})
.unwrap_or_default()
}
}

View file

@ -0,0 +1,339 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContactForm {
pub name: String,
pub email: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
pub subject: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<ContactCategory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_contact_method: Option<ContactMethod>,
#[serde(skip_serializing_if = "Option::is_none")]
pub urgent: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visitor_info: Option<VisitorInfo>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContactSubmission {
pub id: String,
pub name: String,
pub email: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
pub subject: String,
pub message: String,
pub category: ContactCategory,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_contact_method: Option<ContactMethod>,
#[serde(default)]
pub urgent: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub visitor_info: Option<VisitorInfo>,
pub status: ContactStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub responded_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VisitorInfo {
pub is_first_time: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub how_heard_about_us: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interests: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub family_members: Option<Vec<FamilyMember>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prayer_requests: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<Address>,
#[serde(default)]
pub wants_follow_up: bool,
#[serde(default)]
pub wants_newsletter: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FamilyMember {
pub name: String,
pub relationship: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub age_group: Option<AgeGroup>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interests: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Address {
pub street: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub zip_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PrayerRequest {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
pub request: String,
pub category: PrayerCategory,
#[serde(default)]
pub is_public: bool,
#[serde(default)]
pub is_urgent: bool,
#[serde(default)]
pub is_confidential: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub follow_up_requested: Option<bool>,
pub status: PrayerStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub answered_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum ContactCategory {
#[serde(rename = "general")]
General,
#[serde(rename = "pastoral_care")]
PastoralCare,
#[serde(rename = "prayer_request")]
PrayerRequest,
#[serde(rename = "visitor")]
Visitor,
#[serde(rename = "ministry")]
Ministry,
#[serde(rename = "event")]
Event,
#[serde(rename = "technical")]
Technical,
#[serde(rename = "feedback")]
Feedback,
#[serde(rename = "donation")]
Donation,
#[serde(rename = "membership")]
Membership,
#[serde(rename = "baptism")]
Baptism,
#[serde(rename = "wedding")]
Wedding,
#[serde(rename = "funeral")]
Funeral,
#[serde(rename = "other")]
Other,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum ContactMethod {
#[serde(rename = "email")]
Email,
#[serde(rename = "phone")]
Phone,
#[serde(rename = "text")]
Text,
#[serde(rename = "mail")]
Mail,
#[serde(rename = "no_preference")]
NoPreference,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum ContactStatus {
#[serde(rename = "new")]
New,
#[serde(rename = "assigned")]
Assigned,
#[serde(rename = "in_progress")]
InProgress,
#[serde(rename = "responded")]
Responded,
#[serde(rename = "follow_up")]
FollowUp,
#[serde(rename = "completed")]
Completed,
#[serde(rename = "closed")]
Closed,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum PrayerCategory {
#[serde(rename = "health")]
Health,
#[serde(rename = "family")]
Family,
#[serde(rename = "finances")]
Finances,
#[serde(rename = "relationships")]
Relationships,
#[serde(rename = "spiritual")]
Spiritual,
#[serde(rename = "work")]
Work,
#[serde(rename = "travel")]
Travel,
#[serde(rename = "community")]
Community,
#[serde(rename = "praise")]
Praise,
#[serde(rename = "other")]
Other,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum PrayerStatus {
#[serde(rename = "new")]
New,
#[serde(rename = "praying")]
Praying,
#[serde(rename = "answered")]
Answered,
#[serde(rename = "ongoing")]
Ongoing,
#[serde(rename = "closed")]
Closed,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum AgeGroup {
#[serde(rename = "infant")]
Infant,
#[serde(rename = "toddler")]
Toddler,
#[serde(rename = "child")]
Child,
#[serde(rename = "youth")]
Youth,
#[serde(rename = "adult")]
Adult,
#[serde(rename = "senior")]
Senior,
}
impl ContactForm {
pub fn new(name: String, email: String, subject: String, message: String) -> Self {
Self {
name,
email,
phone: None,
subject,
message,
category: None,
preferred_contact_method: None,
urgent: None,
visitor_info: None,
}
}
pub fn with_category(mut self, category: ContactCategory) -> Self {
self.category = Some(category);
self
}
pub fn with_phone(mut self, phone: String) -> Self {
self.phone = Some(phone);
self
}
pub fn with_preferred_method(mut self, method: ContactMethod) -> Self {
self.preferred_contact_method = Some(method);
self
}
pub fn mark_urgent(mut self) -> Self {
self.urgent = Some(true);
self
}
pub fn with_visitor_info(mut self, visitor_info: VisitorInfo) -> Self {
self.visitor_info = Some(visitor_info);
self
}
pub fn is_urgent(&self) -> bool {
self.urgent.unwrap_or(false)
}
pub fn is_visitor(&self) -> bool {
self.visitor_info.is_some()
}
}
impl ContactSubmission {
pub fn is_urgent(&self) -> bool {
self.urgent
}
pub fn is_visitor(&self) -> bool {
self.visitor_info.is_some()
}
pub fn is_open(&self) -> bool {
!matches!(self.status, ContactStatus::Completed | ContactStatus::Closed)
}
pub fn needs_response(&self) -> bool {
matches!(self.status, ContactStatus::New | ContactStatus::Assigned)
}
pub fn response_time(&self) -> Option<chrono::Duration> {
self.responded_at.map(|responded| responded - self.created_at)
}
pub fn age_days(&self) -> i64 {
(Utc::now() - self.created_at).num_days()
}
}
impl PrayerRequest {
pub fn is_urgent(&self) -> bool {
self.is_urgent
}
pub fn is_confidential(&self) -> bool {
self.is_confidential
}
pub fn is_public(&self) -> bool {
self.is_public && !self.is_confidential
}
pub fn is_open(&self) -> bool {
!matches!(self.status, PrayerStatus::Answered | PrayerStatus::Closed)
}
pub fn is_answered(&self) -> bool {
matches!(self.status, PrayerStatus::Answered)
}
pub fn age_days(&self) -> i64 {
(Utc::now() - self.created_at).num_days()
}
}

View file

@ -0,0 +1,349 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize, Deserializer};
use std::fmt;
/// Timezone-aware timestamp from v2 API
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TimezoneTimestamp {
pub utc: DateTime<Utc>,
pub local: String, // "2025-08-13T05:00:00-04:00"
pub timezone: String, // "America/New_York"
}
/// Custom deserializer that handles both v1 (simple string) and v2 (timezone object) formats
fn deserialize_flexible_datetime<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, Visitor};
struct FlexibleDateTimeVisitor;
impl<'de> Visitor<'de> for FlexibleDateTimeVisitor {
type Value = DateTime<Utc>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string timestamp or timezone object")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
// v1 format: simple ISO string
DateTime::parse_from_rfc3339(value)
.map(|dt| dt.with_timezone(&Utc))
.map_err(de::Error::custom)
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
// v2 format: timezone object - extract UTC field
let mut utc_value: Option<DateTime<Utc>> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"utc" => {
utc_value = Some(map.next_value()?);
}
_ => {
// Skip other fields (local, timezone)
let _: serde_json::Value = map.next_value()?;
}
}
}
utc_value.ok_or_else(|| de::Error::missing_field("utc"))
}
}
deserializer.deserialize_any(FlexibleDateTimeVisitor)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Event {
pub id: String,
pub title: String,
pub description: String,
#[serde(deserialize_with = "deserialize_flexible_datetime")]
pub start_time: DateTime<Utc>,
#[serde(deserialize_with = "deserialize_flexible_datetime")]
pub end_time: DateTime<Utc>,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
pub category: EventCategory,
#[serde(default)]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<RecurringType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_attendees: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_attendees: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approved_from: Option<String>,
#[serde(deserialize_with = "deserialize_flexible_datetime")]
pub created_at: DateTime<Utc>,
#[serde(deserialize_with = "deserialize_flexible_datetime")]
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewEvent {
pub title: String,
pub description: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
pub category: EventCategory,
#[serde(default)]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<RecurringType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_attendees: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EventUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_time: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<EventCategory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_featured: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<RecurringType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_attendees: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum EventCategory {
#[serde(rename = "service", alias = "Service")]
Service,
#[serde(rename = "ministry", alias = "Ministry")]
Ministry,
#[serde(rename = "social", alias = "Social")]
Social,
#[serde(rename = "education", alias = "Education")]
Education,
#[serde(rename = "outreach", alias = "Outreach")]
Outreach,
#[serde(rename = "youth", alias = "Youth")]
Youth,
#[serde(rename = "music", alias = "Music")]
Music,
#[serde(rename = "other", alias = "Other")]
Other,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum RecurringType {
#[serde(rename = "daily", alias = "DAILY")]
Daily,
#[serde(rename = "weekly", alias = "WEEKLY")]
Weekly,
#[serde(rename = "biweekly", alias = "BIWEEKLY")]
Biweekly,
#[serde(rename = "monthly", alias = "MONTHLY")]
Monthly,
#[serde(rename = "first_tuesday", alias = "FIRST_TUESDAY")]
FirstTuesday,
#[serde(rename = "first_sabbath", alias = "FIRST_SABBATH")]
FirstSabbath,
#[serde(rename = "last_sabbath", alias = "LAST_SABBATH")]
LastSabbath,
#[serde(rename = "2nd/3rd Saturday Monthly")]
SecondThirdSaturday,
}
impl Event {
pub fn duration_minutes(&self) -> i64 {
(self.end_time - self.start_time).num_minutes()
}
pub fn has_registration(&self) -> bool {
self.registration_url.is_some()
}
pub fn is_full(&self) -> bool {
match (self.max_attendees, self.current_attendees) {
(Some(max), Some(current)) => current >= max,
_ => false,
}
}
pub fn spots_remaining(&self) -> Option<u32> {
match (self.max_attendees, self.current_attendees) {
(Some(max), Some(current)) => Some(max.saturating_sub(current)),
_ => None,
}
}
}
impl fmt::Display for EventCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EventCategory::Service => write!(f, "Service"),
EventCategory::Ministry => write!(f, "Ministry"),
EventCategory::Social => write!(f, "Social"),
EventCategory::Education => write!(f, "Education"),
EventCategory::Outreach => write!(f, "Outreach"),
EventCategory::Youth => write!(f, "Youth"),
EventCategory::Music => write!(f, "Music"),
EventCategory::Other => write!(f, "Other"),
}
}
}
impl Event {
pub fn formatted_date(&self) -> String {
self.start_time.format("%A, %B %d, %Y").to_string()
}
/// Returns formatted date range for multi-day events, single date for same-day events
pub fn formatted_date_range(&self) -> String {
let start_date = self.start_time.date_naive();
let end_date = self.end_time.date_naive();
if start_date == end_date {
// Same day event
self.start_time.format("%A, %B %d, %Y").to_string()
} else {
// Multi-day event
let start_formatted = self.start_time.format("%A, %B %d, %Y").to_string();
let end_formatted = self.end_time.format("%A, %B %d, %Y").to_string();
format!("{} - {}", start_formatted, end_formatted)
}
}
pub fn formatted_start_time(&self) -> String {
// Convert UTC to user's local timezone automatically
let local_time = self.start_time.with_timezone(&chrono::Local);
local_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string()
}
pub fn formatted_end_time(&self) -> String {
// Convert UTC to user's local timezone automatically
let local_time = self.end_time.with_timezone(&chrono::Local);
local_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string()
}
pub fn clean_description(&self) -> String {
html2text::from_read(self.description.as_bytes(), 80)
.replace('\n', " ")
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
}
}
/// Event submission for public submission endpoint
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EventSubmission {
pub title: String,
pub description: String,
pub start_time: String, // ISO string format
pub end_time: String, // ISO string format
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
pub category: String, // String to match API exactly
#[serde(default)]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bulletin_week: Option<String>, // Date string in YYYY-MM-DD format
pub submitter_email: String,
}
impl EventSubmission {
/// Parse start_time string to DateTime<Utc>
pub fn parse_start_time(&self) -> Option<DateTime<Utc>> {
crate::utils::parse_datetime_flexible(&self.start_time)
}
/// Parse end_time string to DateTime<Utc>
pub fn parse_end_time(&self) -> Option<DateTime<Utc>> {
crate::utils::parse_datetime_flexible(&self.end_time)
}
/// Validate that both start and end times can be parsed
pub fn validate_times(&self) -> bool {
self.parse_start_time().is_some() && self.parse_end_time().is_some()
}
}
/// Pending event for admin management
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PendingEvent {
pub id: String,
pub title: String,
pub description: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_url: Option<String>,
pub category: EventCategory,
#[serde(default)]
pub is_featured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurring_type: Option<RecurringType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bulletin_week: Option<String>,
pub submitter_email: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View file

@ -0,0 +1,28 @@
pub mod common;
pub mod event;
pub mod bulletin;
pub mod config;
pub mod contact;
pub mod sermon;
pub mod streaming;
pub mod auth;
pub mod bible;
pub mod client_models;
pub mod v2;
pub mod admin;
pub use common::*;
pub use event::*;
pub use bulletin::*;
pub use config::*;
pub use contact::*;
pub use sermon::*;
pub use streaming::*;
pub use auth::*;
pub use bible::*;
pub use client_models::*;
pub use v2::*;
pub use admin::*;
// Re-export livestream types from client module for convenience
pub use crate::client::livestream::{StreamStatus, LiveStream};

View file

@ -0,0 +1,376 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// API response structure for sermons from the external API
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiSermon {
pub id: String,
pub title: String,
pub speaker: Option<String>,
pub date: Option<String>,
pub duration: String, // Duration as string like "1:13:01"
pub description: Option<String>,
pub audio_url: Option<String>,
pub video_url: Option<String>,
pub media_type: Option<String>,
pub thumbnail: Option<String>,
pub scripture_reading: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Sermon {
pub id: String,
pub title: String,
pub speaker: String,
pub description: String,
pub date: DateTime<Utc>,
pub scripture_reference: String,
pub series: Option<String>,
pub duration_string: Option<String>, // Raw duration from API (e.g., "2:34:49")
pub media_url: Option<String>,
pub audio_url: Option<String>,
pub video_url: Option<String>,
pub transcript: Option<String>,
pub thumbnail: Option<String>,
pub tags: Option<Vec<String>>,
pub category: SermonCategory,
pub is_featured: bool,
pub view_count: u32,
pub download_count: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewSermon {
pub title: String,
pub speaker: String,
pub description: String,
pub date: DateTime<Utc>,
pub scripture_reference: String,
pub series: Option<String>,
pub duration_string: Option<String>,
pub media_url: Option<String>,
pub audio_url: Option<String>,
pub video_url: Option<String>,
pub transcript: Option<String>,
pub thumbnail: Option<String>,
pub tags: Option<Vec<String>>,
pub category: SermonCategory,
pub is_featured: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SermonSeries {
pub id: String,
pub title: String,
pub description: String,
pub speaker: String,
pub start_date: DateTime<Utc>,
pub end_date: Option<DateTime<Utc>>,
pub thumbnail: Option<String>,
pub sermons: Vec<Sermon>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SermonNote {
pub id: String,
pub sermon_id: String,
pub user_id: String,
pub content: String,
pub timestamp_seconds: Option<u32>,
pub is_private: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SermonFeedback {
pub id: String,
pub sermon_id: String,
pub user_name: Option<String>,
pub user_email: Option<String>,
pub rating: Option<u8>, // 1-5 stars
pub comment: Option<String>,
pub is_public: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum SermonCategory {
#[serde(rename = "regular")]
Regular,
#[serde(rename = "evangelistic")]
Evangelistic,
#[serde(rename = "youth")]
Youth,
#[serde(rename = "children")]
Children,
#[serde(rename = "special")]
Special,
#[serde(rename = "prophecy")]
Prophecy,
#[serde(rename = "health")]
Health,
#[serde(rename = "stewardship")]
Stewardship,
#[serde(rename = "testimony")]
Testimony,
#[serde(rename = "holiday")]
Holiday,
#[serde(rename = "communion")]
Communion,
#[serde(rename = "baptism")]
Baptism,
#[serde(rename = "wedding")]
Wedding,
#[serde(rename = "funeral")]
Funeral,
#[serde(rename = "other")]
Other,
#[serde(rename = "livestream_archive")]
LivestreamArchive,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SermonSearch {
pub query: Option<String>,
pub speaker: Option<String>,
pub category: Option<SermonCategory>,
pub series: Option<String>,
pub date_from: Option<DateTime<Utc>>,
pub date_to: Option<DateTime<Utc>>,
pub tags: Option<Vec<String>>,
pub featured_only: Option<bool>,
pub has_video: Option<bool>,
pub has_audio: Option<bool>,
pub has_transcript: Option<bool>,
pub min_duration: Option<u32>,
pub max_duration: Option<u32>,
}
impl Default for SermonSearch {
fn default() -> Self {
Self {
query: None,
speaker: None,
category: None,
series: None,
date_from: None,
date_to: None,
tags: None,
featured_only: None,
has_video: None,
has_audio: None,
has_transcript: None,
min_duration: None,
max_duration: None,
}
}
}
impl SermonSearch {
pub fn new() -> Self {
Self::default()
}
pub fn with_query(mut self, query: String) -> Self {
self.query = Some(query);
self
}
pub fn with_speaker(mut self, speaker: String) -> Self {
self.speaker = Some(speaker);
self
}
pub fn with_category(mut self, category: SermonCategory) -> Self {
self.category = Some(category);
self
}
pub fn with_series(mut self, series: String) -> Self {
self.series = Some(series);
self
}
pub fn with_date_range(mut self, from: DateTime<Utc>, to: DateTime<Utc>) -> Self {
self.date_from = Some(from);
self.date_to = Some(to);
self
}
pub fn featured_only(mut self) -> Self {
self.featured_only = Some(true);
self
}
pub fn with_video(mut self) -> Self {
self.has_video = Some(true);
self
}
pub fn with_audio(mut self) -> Self {
self.has_audio = Some(true);
self
}
pub fn with_transcript(mut self) -> Self {
self.has_transcript = Some(true);
self
}
}
impl Sermon {
pub fn duration_formatted(&self) -> String {
self.duration_string.clone().unwrap_or_else(|| "Unknown".to_string())
}
pub fn has_media(&self) -> bool {
self.media_url.is_some() || self.audio_url.is_some() || self.video_url.is_some()
}
pub fn has_video(&self) -> bool {
self.video_url.is_some() || self.media_url.is_some()
}
pub fn has_audio(&self) -> bool {
self.audio_url.is_some() || self.media_url.is_some()
}
pub fn has_transcript(&self) -> bool {
self.transcript.is_some()
}
pub fn is_recent(&self) -> bool {
let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
self.date > thirty_days_ago
}
pub fn is_popular(&self) -> bool {
self.view_count > 100 || self.download_count > 50
}
pub fn get_tags(&self) -> Vec<String> {
self.tags.clone().unwrap_or_default()
}
pub fn matches_search(&self, search: &SermonSearch) -> bool {
if let Some(query) = &search.query {
let query_lower = query.to_lowercase();
if !self.title.to_lowercase().contains(&query_lower)
&& !self.description.to_lowercase().contains(&query_lower)
&& !self.speaker.to_lowercase().contains(&query_lower)
&& !self.scripture_reference.to_lowercase().contains(&query_lower) {
return false;
}
}
if let Some(speaker) = &search.speaker {
if !self.speaker.to_lowercase().contains(&speaker.to_lowercase()) {
return false;
}
}
if let Some(category) = &search.category {
if self.category != *category {
return false;
}
}
if let Some(series) = &search.series {
match &self.series {
Some(sermon_series) => {
if !sermon_series.to_lowercase().contains(&series.to_lowercase()) {
return false;
}
}
None => return false,
}
}
if let Some(date_from) = search.date_from {
if self.date < date_from {
return false;
}
}
if let Some(date_to) = search.date_to {
if self.date > date_to {
return false;
}
}
if let Some(true) = search.featured_only {
if !self.is_featured {
return false;
}
}
if let Some(true) = search.has_video {
if !self.has_video() {
return false;
}
}
if let Some(true) = search.has_audio {
if !self.has_audio() {
return false;
}
}
if let Some(true) = search.has_transcript {
if !self.has_transcript() {
return false;
}
}
true
}
}
impl SermonSeries {
pub fn is_active(&self) -> bool {
self.is_active && self.end_date.map_or(true, |end| end > Utc::now())
}
pub fn sermon_count(&self) -> usize {
self.sermons.len()
}
pub fn total_duration(&self) -> Option<u32> {
if self.sermons.is_empty() {
return None;
}
// Since we now use duration_string, we can't easily sum durations
// Return None for now - this would need proper duration parsing if needed
None
}
pub fn latest_sermon(&self) -> Option<&Sermon> {
self.sermons
.iter()
.max_by_key(|s| s.date)
}
pub fn duration_formatted(&self) -> String {
match self.total_duration() {
Some(seconds) => {
let minutes = seconds / 60;
let hours = minutes / 60;
let remaining_minutes = minutes % 60;
if hours > 0 {
format!("{}h {}m", hours, remaining_minutes)
} else {
format!("{}m", minutes)
}
}
None => "Unknown".to_string(),
}
}
}

View file

@ -0,0 +1,157 @@
use serde::{Deserialize, Serialize};
/// Device streaming capabilities
#[derive(Debug, Clone, PartialEq)]
pub enum StreamingCapability {
/// Device supports AV1 codec (direct stream)
AV1,
/// Device needs HLS H.264 fallback
HLS,
}
/// Streaming URL configuration
#[derive(Debug, Clone)]
pub struct StreamingUrl {
pub url: String,
pub capability: StreamingCapability,
}
/// Device capability detection
pub struct DeviceCapabilities;
impl DeviceCapabilities {
/// Detect device streaming capability
/// For now, this is a simple implementation that can be expanded
#[cfg(target_os = "ios")]
pub fn detect_capability() -> StreamingCapability {
// Use sysctlbyname to get device model on iOS
use std::ffi::{CStr, CString};
use std::mem;
unsafe {
let name = CString::new("hw.model").unwrap();
let mut size: libc::size_t = 0;
// First call to get the size
if libc::sysctlbyname(
name.as_ptr(),
std::ptr::null_mut(),
&mut size,
std::ptr::null_mut(),
0,
) != 0 {
println!("🎬 DEBUG: Failed to get model size, defaulting to HLS");
return StreamingCapability::HLS;
}
// Allocate buffer and get the actual value
let mut buffer = vec![0u8; size];
if libc::sysctlbyname(
name.as_ptr(),
buffer.as_mut_ptr() as *mut libc::c_void,
&mut size,
std::ptr::null_mut(),
0,
) != 0 {
println!("🎬 DEBUG: Failed to get model value, defaulting to HLS");
return StreamingCapability::HLS;
}
// Convert to string
if let Ok(model_cstr) = CStr::from_bytes_with_nul(&buffer[..size]) {
if let Ok(model) = model_cstr.to_str() {
let model = model.to_lowercase();
println!("🎬 DEBUG: Detected device model: {}", model);
// iPhone models with AV1 hardware decoding support:
// Marketing names: iPhone16,x = iPhone 15 Pro/Pro Max, iPhone17,x = iPhone 16 series
// Internal codenames: d9xap = iPhone 16 series, d8xap = iPhone 15 Pro series
if model.starts_with("iphone16,") || model.starts_with("iphone17,") ||
model.starts_with("d94ap") || model.starts_with("d93ap") ||
model.starts_with("d84ap") || model.starts_with("d83ap") {
println!("🎬 DEBUG: Device {} supports AV1 hardware decoding", model);
return StreamingCapability::AV1;
}
println!("🎬 DEBUG: Device {} does not support AV1, using HLS fallback", model);
return StreamingCapability::HLS;
}
}
println!("🎬 DEBUG: Failed to parse model string, defaulting to HLS");
StreamingCapability::HLS
}
}
#[cfg(not(target_os = "ios"))]
pub fn detect_capability() -> StreamingCapability {
// Default to HLS for other platforms for now
StreamingCapability::HLS
}
/// Generate streaming URL based on capability and media ID
pub fn get_streaming_url(base_url: &str, media_id: &str, capability: StreamingCapability) -> StreamingUrl {
// Add timestamp for cache busting to ensure fresh streams
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let url = match capability {
StreamingCapability::AV1 => {
format!("{}/api/media/stream/{}?t={}", base_url.trim_end_matches('/'), media_id, timestamp)
}
StreamingCapability::HLS => {
format!("{}/api/media/stream/{}/playlist.m3u8?t={}", base_url.trim_end_matches('/'), media_id, timestamp)
}
};
StreamingUrl { url, capability }
}
/// Get optimal streaming URL for current device
pub fn get_optimal_streaming_url(base_url: &str, media_id: &str) -> StreamingUrl {
let capability = Self::detect_capability();
Self::get_streaming_url(base_url, media_id, capability)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_av1_url_generation() {
let url = DeviceCapabilities::get_streaming_url(
"https://api.rockvilletollandsda.church",
"test-id-123",
StreamingCapability::AV1
);
assert_eq!(url.url, "https://api.rockvilletollandsda.church/api/media/stream/test-id-123");
assert_eq!(url.capability, StreamingCapability::AV1);
}
#[test]
fn test_hls_url_generation() {
let url = DeviceCapabilities::get_streaming_url(
"https://api.rockvilletollandsda.church",
"test-id-123",
StreamingCapability::HLS
);
assert_eq!(url.url, "https://api.rockvilletollandsda.church/api/media/stream/test-id-123/playlist.m3u8");
assert_eq!(url.capability, StreamingCapability::HLS);
}
#[test]
fn test_base_url_trimming() {
let url = DeviceCapabilities::get_streaming_url(
"https://api.rockvilletollandsda.church/",
"test-id-123",
StreamingCapability::HLS
);
assert_eq!(url.url, "https://api.rockvilletollandsda.church/api/media/stream/test-id-123/playlist.m3u8");
}
}

View file

@ -0,0 +1,15 @@
/// API version enum to specify which API version to use
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiVersion {
V1,
V2,
}
impl ApiVersion {
pub fn path_prefix(&self) -> &'static str {
match self {
ApiVersion::V1 => "",
ApiVersion::V2 => "v2/",
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,310 @@
use crate::models::{ClientEvent, Sermon, Bulletin, BibleVerse};
use chrono::{DateTime, Utc, NaiveDateTime};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedItem {
pub id: String,
pub feed_type: FeedItemType,
pub timestamp: String, // ISO8601 format
pub priority: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum FeedItemType {
#[serde(rename = "event")]
Event {
event: ClientEvent,
},
#[serde(rename = "sermon")]
Sermon {
sermon: Sermon,
},
#[serde(rename = "bulletin")]
Bulletin {
bulletin: Bulletin,
},
#[serde(rename = "verse")]
Verse {
verse: BibleVerse,
},
}
/// Parse date string to DateTime<Utc>, with fallback to current time
fn parse_date_with_fallback(date_str: &str) -> DateTime<Utc> {
// Try ISO8601 format first
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
return dt.with_timezone(&Utc);
}
// Try naive datetime parsing
if let Ok(naive) = NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S") {
return DateTime::from_naive_utc_and_offset(naive, Utc);
}
// Fallback to current time
Utc::now()
}
/// Calculate priority for feed items based on type and recency
fn calculate_priority(feed_type: &FeedItemType, timestamp: &DateTime<Utc>) -> i32 {
let now = Utc::now();
let age_days = (now - *timestamp).num_days().max(0);
match feed_type {
FeedItemType::Event { .. } => {
// Events get highest priority, especially upcoming ones
if *timestamp > now {
1000 // Future events (upcoming)
} else {
800 - (age_days as i32) // Recent past events
}
},
FeedItemType::Sermon { .. } => {
// Sermons get high priority when recent
600 - (age_days as i32)
},
FeedItemType::Bulletin { .. } => {
// Bulletins get medium priority
400 - (age_days as i32)
},
FeedItemType::Verse { .. } => {
// Daily verse always gets consistent priority
300
},
}
}
/// Aggregate and sort home feed items
pub fn aggregate_home_feed(
events: &[ClientEvent],
sermons: &[Sermon],
bulletins: &[Bulletin],
daily_verse: Option<&BibleVerse>
) -> Vec<FeedItem> {
let mut feed_items = Vec::new();
// Add recent sermons (limit to 3)
for sermon in sermons.iter().take(3) {
let timestamp = sermon.date; // Already a DateTime<Utc>
let feed_type = FeedItemType::Sermon { sermon: sermon.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("sermon_{}", sermon.id),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Add upcoming events (limit to 2)
for event in events.iter().take(2) {
let timestamp = parse_date_with_fallback(&event.created_at);
let feed_type = FeedItemType::Event { event: event.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("event_{}", event.id),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Add most recent bulletin
if let Some(bulletin) = bulletins.first() {
let timestamp = parse_date_with_fallback(&bulletin.date.to_string());
let feed_type = FeedItemType::Bulletin { bulletin: bulletin.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("bulletin_{}", bulletin.id),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Add daily verse
if let Some(verse) = daily_verse {
let timestamp = Utc::now();
let feed_type = FeedItemType::Verse { verse: verse.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("verse_{}", verse.reference),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Sort by priority (highest first), then by timestamp (newest first)
feed_items.sort_by(|a, b| {
b.priority.cmp(&a.priority)
.then_with(|| b.timestamp.cmp(&a.timestamp))
});
feed_items
}
/// Media type enumeration for content categorization
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MediaType {
Sermons,
LiveStreams,
}
impl MediaType {
pub fn display_name(&self) -> &'static str {
match self {
MediaType::Sermons => "Sermons",
MediaType::LiveStreams => "Live Archives",
}
}
pub fn icon_name(&self) -> &'static str {
match self {
MediaType::Sermons => "play.rectangle.fill",
MediaType::LiveStreams => "dot.radiowaves.left.and.right",
}
}
}
/// Get sermons or livestreams based on media type
pub fn get_media_content(sermons: &[Sermon], media_type: &MediaType) -> Vec<Sermon> {
match media_type {
MediaType::Sermons => {
// Filter for regular sermons (non-livestream)
sermons.iter()
.filter(|sermon| !sermon.title.to_lowercase().contains("livestream"))
.cloned()
.collect()
},
MediaType::LiveStreams => {
// Filter for livestream archives
sermons.iter()
.filter(|sermon| sermon.title.to_lowercase().contains("livestream"))
.cloned()
.collect()
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ClientEvent, Sermon, Bulletin, BibleVerse};
fn create_sample_event(id: &str, title: &str) -> ClientEvent {
ClientEvent {
id: id.to_string(),
title: title.to_string(),
description: "Sample description".to_string(),
date: "2025-01-15".to_string(),
start_time: "6:00 PM".to_string(),
end_time: "8:00 PM".to_string(),
location: "Sample Location".to_string(),
location_url: None,
image: None,
thumbnail: None,
category: "Social".to_string(),
is_featured: false,
recurring_type: None,
tags: None,
contact_email: None,
contact_phone: None,
registration_url: None,
max_attendees: None,
current_attendees: None,
created_at: "2025-01-10T10:00:00Z".to_string(),
updated_at: "2025-01-10T10:00:00Z".to_string(),
duration_minutes: 120,
has_registration: false,
is_full: false,
spots_remaining: None,
}
}
fn create_sample_sermon(id: &str, title: &str) -> Sermon {
Sermon {
id: id.to_string(),
title: title.to_string(),
description: Some("Sample sermon".to_string()),
date: Some("2025-01-10T10:00:00Z".to_string()),
video_url: Some("https://example.com/video".to_string()),
audio_url: None,
thumbnail_url: None,
duration: None,
speaker: Some("Pastor Smith".to_string()),
series: None,
scripture_references: None,
tags: None,
}
}
#[test]
fn test_aggregate_home_feed() {
let events = vec![
create_sample_event("1", "Event 1"),
create_sample_event("2", "Event 2"),
];
let sermons = vec![
create_sample_sermon("1", "Sermon 1"),
create_sample_sermon("2", "Sermon 2"),
];
let bulletins = vec![
Bulletin {
id: "1".to_string(),
title: "Weekly Bulletin".to_string(),
date: "2025-01-12T10:00:00Z".to_string(),
pdf_url: "https://example.com/bulletin.pdf".to_string(),
description: Some("This week's bulletin".to_string()),
thumbnail_url: None,
}
];
let verse = BibleVerse {
text: "For God so loved the world...".to_string(),
reference: "John 3:16".to_string(),
version: Some("KJV".to_string()),
};
let feed = aggregate_home_feed(&events, &sermons, &bulletins, Some(&verse));
assert!(feed.len() >= 4); // Should have events, sermons, bulletin, and verse
// Check that items are sorted by priority
for i in 1..feed.len() {
assert!(feed[i-1].priority >= feed[i].priority);
}
}
#[test]
fn test_media_type_display() {
assert_eq!(MediaType::Sermons.display_name(), "Sermons");
assert_eq!(MediaType::LiveStreams.display_name(), "Live Archives");
assert_eq!(MediaType::Sermons.icon_name(), "play.rectangle.fill");
assert_eq!(MediaType::LiveStreams.icon_name(), "dot.radiowaves.left.and.right");
}
#[test]
fn test_get_media_content() {
let sermons = vec![
create_sample_sermon("1", "Regular Sermon"),
create_sample_sermon("2", "Livestream Service"),
create_sample_sermon("3", "Another Sermon"),
];
let regular_sermons = get_media_content(&sermons, &MediaType::Sermons);
assert_eq!(regular_sermons.len(), 2);
let livestreams = get_media_content(&sermons, &MediaType::LiveStreams);
assert_eq!(livestreams.len(), 1);
assert!(livestreams[0].title.contains("Livestream"));
}
}

View file

@ -0,0 +1,159 @@
use crate::models::ClientEvent;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormattedEvent {
pub formatted_time: String,
pub formatted_date_time: String,
pub is_multi_day: bool,
pub formatted_date_range: String,
}
/// Format time range for display
pub fn format_time_range(start_time: &str, end_time: &str) -> String {
format!("{} - {}", start_time, end_time)
}
/// Check if event appears to be multi-day based on date format
pub fn is_multi_day_event(date: &str) -> bool {
date.contains(" - ")
}
/// Format date and time for display, handling multi-day events
pub fn format_date_time(date: &str, start_time: &str, end_time: &str) -> String {
if is_multi_day_event(date) {
// For multi-day events, integrate times with their respective dates
let components: Vec<&str> = date.split(" - ").collect();
if components.len() == 2 {
format!("{} at {} - {} at {}", components[0], start_time, components[1], end_time)
} else {
date.to_string() // Fallback to original date
}
} else {
// Single day events: return just the date (time displayed separately)
date.to_string()
}
}
/// Format a client event with all display formatting logic
pub fn format_event_for_display(event: &ClientEvent) -> FormattedEvent {
let start_time = &event.start_time;
let end_time = &event.end_time;
// Derive formatted date from start_time since ClientEvent no longer has date field
let formatted_date = format_date_from_timestamp(start_time);
FormattedEvent {
formatted_time: format_time_range(start_time, end_time),
formatted_date_time: format_date_time(&formatted_date, start_time, end_time),
is_multi_day: is_multi_day_event(&formatted_date),
formatted_date_range: formatted_date,
}
}
/// Extract formatted date from ISO timestamp
fn format_date_from_timestamp(timestamp: &str) -> String {
use chrono::{DateTime, FixedOffset};
if let Ok(dt) = DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%z") {
dt.format("%A, %B %d, %Y").to_string()
} else {
"Date TBD".to_string()
}
}
/// Format duration in minutes to human readable format
pub fn format_duration_minutes(minutes: i64) -> String {
if minutes < 60 {
format!("{} min", minutes)
} else {
let hours = minutes / 60;
let remaining_minutes = minutes % 60;
if remaining_minutes == 0 {
format!("{} hr", hours)
} else {
format!("{} hr {} min", hours, remaining_minutes)
}
}
}
/// Format spots remaining for events
pub fn format_spots_remaining(current: Option<u32>, max: Option<u32>) -> Option<String> {
match (current, max) {
(Some(current), Some(max)) => {
let remaining = max.saturating_sub(current);
if remaining == 0 {
Some("Event Full".to_string())
} else {
Some(format!("{} spots remaining", remaining))
}
}
_ => None,
}
}
/// Check if event registration is full
pub fn is_event_full(current: Option<u32>, max: Option<u32>) -> bool {
match (current, max) {
(Some(current), Some(max)) => current >= max,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_time_range() {
assert_eq!(format_time_range("9:00 AM", "5:00 PM"), "9:00 AM - 5:00 PM");
assert_eq!(format_time_range("", ""), " - ");
}
#[test]
fn test_is_multi_day_event() {
assert!(is_multi_day_event("Saturday, Aug 30, 2025 - Sunday, Aug 31, 2025"));
assert!(!is_multi_day_event("Saturday, Aug 30, 2025"));
assert!(!is_multi_day_event(""));
}
#[test]
fn test_format_date_time() {
// Single day event
let result = format_date_time("Saturday, Aug 30, 2025", "6:00 PM", "8:00 PM");
assert_eq!(result, "Saturday, Aug 30, 2025");
// Multi-day event
let result = format_date_time(
"Saturday, Aug 30, 2025 - Sunday, Aug 31, 2025",
"6:00 PM",
"6:00 AM"
);
assert_eq!(result, "Saturday, Aug 30, 2025 at 6:00 PM - Sunday, Aug 31, 2025 at 6:00 AM");
}
#[test]
fn test_format_duration_minutes() {
assert_eq!(format_duration_minutes(30), "30 min");
assert_eq!(format_duration_minutes(60), "1 hr");
assert_eq!(format_duration_minutes(90), "1 hr 30 min");
assert_eq!(format_duration_minutes(120), "2 hr");
}
#[test]
fn test_format_spots_remaining() {
assert_eq!(format_spots_remaining(Some(8), Some(10)), Some("2 spots remaining".to_string()));
assert_eq!(format_spots_remaining(Some(10), Some(10)), Some("Event Full".to_string()));
assert_eq!(format_spots_remaining(None, Some(10)), None);
assert_eq!(format_spots_remaining(Some(5), None), None);
}
#[test]
fn test_is_event_full() {
assert!(is_event_full(Some(10), Some(10)));
assert!(is_event_full(Some(11), Some(10))); // Over capacity
assert!(!is_event_full(Some(9), Some(10)));
assert!(!is_event_full(None, Some(10)));
assert!(!is_event_full(Some(5), None));
}
}

View file

@ -0,0 +1,9 @@
pub mod scripture;
pub mod validation;
pub mod formatting;
pub mod feed;
pub use scripture::*;
pub use validation::*;
pub use formatting::*;
pub use feed::*;

View file

@ -0,0 +1,164 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptureSection {
pub verse: String,
pub reference: String,
}
/// Format raw scripture text into structured sections with verses and references
pub fn format_scripture_text(text: &str) -> Vec<ScriptureSection> {
// Handle single-line format where verse and reference are together
if text.contains(" KJV") && !text.contains('\n') {
// Single line format: "verse text. Book chapter:verse KJV"
if let Some(kjv_pos) = text.rfind(" KJV") {
let before_kjv = &text[..kjv_pos];
// Find the last period or other punctuation that separates verse from reference
if let Some(last_period) = before_kjv.rfind('.') {
if let Some(reference_start) = before_kjv[last_period..].find(char::is_alphabetic) {
let actual_start = last_period + reference_start;
let verse_text = format!("{}.", &before_kjv[..last_period]);
let reference = format!("{} KJV", &before_kjv[actual_start..]);
return vec![ScriptureSection {
verse: verse_text.trim().to_string(),
reference: reference.trim().to_string(),
}];
}
}
}
// Fallback: treat entire text as verse with no separate reference
return vec![ScriptureSection {
verse: text.to_string(),
reference: String::new(),
}];
}
// Multi-line format (original logic)
let sections: Vec<&str> = text.split('\n').collect();
let mut formatted_sections = Vec::new();
let mut current_verse = String::new();
for section in sections {
let trimmed = section.trim();
if trimmed.is_empty() {
continue;
}
// Check if this line is a reference (contains "KJV" at the end)
if trimmed.ends_with("KJV") {
// This is a reference for the verse we just accumulated
if !current_verse.is_empty() {
formatted_sections.push(ScriptureSection {
verse: current_verse.clone(),
reference: trimmed.to_string(),
});
current_verse.clear(); // Reset for next verse
}
} else {
// This is verse text
if !current_verse.is_empty() {
current_verse.push(' ');
}
current_verse.push_str(trimmed);
}
}
// Add any remaining verse without a reference
if !current_verse.is_empty() {
formatted_sections.push(ScriptureSection {
verse: current_verse,
reference: String::new(),
});
}
formatted_sections
}
/// Extract scripture references from text (e.g., "Joel 2:28 KJV" patterns)
pub fn extract_scripture_references(text: &str) -> String {
let pattern = r"([1-3]?\s*[A-Za-z]+\s+\d+:\d+(?:-\d+)?)\s+KJV";
match Regex::new(pattern) {
Ok(regex) => {
let references: Vec<String> = regex
.captures_iter(text)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().trim().to_string()))
.collect();
if references.is_empty() {
"Scripture Reading".to_string()
} else {
references.join(", ")
}
}
Err(_) => "Scripture Reading".to_string(),
}
}
/// Create standardized share text for sermons
pub fn create_sermon_share_text(title: &str, speaker: &str, video_url: Option<&str>, audio_url: Option<&str>) -> Vec<String> {
let mut items = Vec::new();
// Create share text
let share_text = format!("Check out this sermon: \"{}\" by {}", title, speaker);
items.push(share_text);
// Add video URL if available, otherwise audio URL
if let Some(url) = video_url {
items.push(url.to_string());
} else if let Some(url) = audio_url {
items.push(url.to_string());
}
items
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_line_scripture_format() {
let input = "And it shall come to pass afterward, that I will pour out my spirit upon all flesh. Joel 2:28 KJV";
let result = format_scripture_text(input);
assert_eq!(result.len(), 1);
assert_eq!(result[0].verse, "And it shall come to pass afterward, that I will pour out my spirit upon all flesh.");
assert_eq!(result[0].reference, "Joel 2:28 KJV");
}
#[test]
fn test_multi_line_scripture_format() {
let input = "And it shall come to pass afterward, that I will pour out my spirit upon all flesh\nJoel 2:28 KJV\nQuench not the Spirit. Despise not prophesyings.\n1 Thessalonians 5:19-21 KJV";
let result = format_scripture_text(input);
assert_eq!(result.len(), 2);
assert_eq!(result[0].verse, "And it shall come to pass afterward, that I will pour out my spirit upon all flesh");
assert_eq!(result[0].reference, "Joel 2:28 KJV");
assert_eq!(result[1].verse, "Quench not the Spirit. Despise not prophesyings.");
assert_eq!(result[1].reference, "1 Thessalonians 5:19-21 KJV");
}
#[test]
fn test_extract_scripture_references() {
let input = "Some text with Joel 2:28 KJV and 1 Thessalonians 5:19-21 KJV references";
let result = extract_scripture_references(input);
assert_eq!(result, "Joel 2:28, 1 Thessalonians 5:19-21");
}
#[test]
fn test_create_sermon_share_text() {
let result = create_sermon_share_text(
"Test Sermon",
"John Doe",
Some("https://example.com/video"),
Some("https://example.com/audio")
);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "Check out this sermon: \"Test Sermon\" by John Doe");
assert_eq!(result[1], "https://example.com/video");
}
}

View file

@ -0,0 +1,249 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc, NaiveDateTime};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub is_valid: bool,
pub errors: Vec<String>,
}
impl ValidationResult {
pub fn valid() -> Self {
Self {
is_valid: true,
errors: Vec::new(),
}
}
pub fn invalid(errors: Vec<String>) -> Self {
Self {
is_valid: false,
errors,
}
}
pub fn add_error(&mut self, error: String) {
self.errors.push(error);
self.is_valid = false;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactFormData {
pub name: String,
pub email: String,
pub phone: String,
pub message: String,
pub subject: String,
}
/// Validate email address using regex
pub fn is_valid_email(email: &str) -> bool {
let email_regex = Regex::new(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$").unwrap();
email_regex.is_match(email)
}
/// Validate phone number - must be exactly 10 digits
pub fn is_valid_phone(phone: &str) -> bool {
let digits_only: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
digits_only.len() == 10
}
/// Validate contact form with all business rules
pub fn validate_contact_form(form_data: &ContactFormData) -> ValidationResult {
let mut errors = Vec::new();
let trimmed_name = form_data.name.trim();
let trimmed_email = form_data.email.trim();
let trimmed_phone = form_data.phone.trim();
let trimmed_message = form_data.message.trim();
// Name validation
if trimmed_name.is_empty() || trimmed_name.len() < 2 {
errors.push("Name must be at least 2 characters".to_string());
}
// Email validation
if trimmed_email.is_empty() {
errors.push("Email is required".to_string());
} else if !is_valid_email(trimmed_email) {
errors.push("Please enter a valid email address".to_string());
}
// Phone validation (optional, but if provided must be valid)
if !trimmed_phone.is_empty() && !is_valid_phone(trimmed_phone) {
errors.push("Please enter a valid phone number".to_string());
}
// Message validation
if trimmed_message.is_empty() {
errors.push("Message is required".to_string());
} else if trimmed_message.len() < 10 {
errors.push("Message must be at least 10 characters".to_string());
}
if errors.is_empty() {
ValidationResult::valid()
} else {
ValidationResult::invalid(errors)
}
}
/// Sanitize and trim form input
pub fn sanitize_form_input(input: &str) -> String {
input.trim().to_string()
}
/// Parse date from various frontend formats
/// Supports: "2025-06-28T23:00", "2025-06-28 23:00", and RFC3339 formats
pub fn parse_datetime_flexible(date_str: &str) -> Option<DateTime<Utc>> {
let trimmed = date_str.trim();
// First try RFC3339/ISO 8601 with timezone info
if trimmed.contains('Z') || trimmed.contains('+') || trimmed.contains('-') && trimmed.rfind('-').map_or(false, |i| i > 10) {
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
return Some(dt.with_timezone(&Utc));
}
// Try ISO 8601 with 'Z' suffix
if let Ok(dt) = chrono::DateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.3fZ") {
return Some(dt.with_timezone(&Utc));
}
// Try ISO 8601 without milliseconds but with Z
if let Ok(dt) = chrono::DateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%SZ") {
return Some(dt.with_timezone(&Utc));
}
}
// Try local datetime formats (no timezone info) - treat as UTC
let local_formats = [
"%Y-%m-%dT%H:%M:%S%.3f", // ISO 8601 with milliseconds, no timezone
"%Y-%m-%dT%H:%M:%S", // ISO 8601 no milliseconds, no timezone
"%Y-%m-%dT%H:%M", // ISO 8601 no seconds, no timezone (frontend format)
"%Y-%m-%d %H:%M:%S", // Space separated with seconds
"%Y-%m-%d %H:%M", // Space separated no seconds
"%m/%d/%Y %H:%M:%S", // US format with seconds
"%m/%d/%Y %H:%M", // US format no seconds
];
for format in &local_formats {
if let Ok(naive_dt) = NaiveDateTime::parse_from_str(trimmed, format) {
return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc));
}
}
// Try date-only formats (no time) - set time to midnight UTC
let date_formats = [
"%Y-%m-%d", // ISO date
"%m/%d/%Y", // US date
"%d/%m/%Y", // European date
];
for format in &date_formats {
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, format) {
return Some(DateTime::from_naive_utc_and_offset(
naive_date.and_hms_opt(0, 0, 0).unwrap(),
Utc,
));
}
}
None
}
/// Validate datetime string can be parsed
pub fn is_valid_datetime(datetime_str: &str) -> bool {
parse_datetime_flexible(datetime_str).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_email() {
assert!(is_valid_email("test@example.com"));
assert!(is_valid_email("user.name+tag@domain.co.uk"));
assert!(!is_valid_email("invalid.email"));
assert!(!is_valid_email("@domain.com"));
assert!(!is_valid_email("user@"));
}
#[test]
fn test_valid_phone() {
assert!(is_valid_phone("1234567890"));
assert!(is_valid_phone("(123) 456-7890"));
assert!(is_valid_phone("123-456-7890"));
assert!(!is_valid_phone("12345"));
assert!(!is_valid_phone("12345678901"));
assert!(!is_valid_phone("abc1234567"));
}
#[test]
fn test_contact_form_validation() {
let valid_form = ContactFormData {
name: "John Doe".to_string(),
email: "john@example.com".to_string(),
phone: "1234567890".to_string(),
message: "This is a test message with enough characters.".to_string(),
subject: "Test Subject".to_string(),
};
let result = validate_contact_form(&valid_form);
assert!(result.is_valid);
assert!(result.errors.is_empty());
let invalid_form = ContactFormData {
name: "A".to_string(), // Too short
email: "invalid-email".to_string(), // Invalid email
phone: "123".to_string(), // Invalid phone
message: "Short".to_string(), // Too short message
subject: "".to_string(),
};
let result = validate_contact_form(&invalid_form);
assert!(!result.is_valid);
assert_eq!(result.errors.len(), 4);
}
#[test]
fn test_parse_datetime_flexible() {
// Test frontend format (the main case we're solving)
assert!(parse_datetime_flexible("2025-06-28T23:00").is_some());
// Test RFC3339 with Z
assert!(parse_datetime_flexible("2024-01-15T14:30:00Z").is_some());
// Test RFC3339 with timezone offset
assert!(parse_datetime_flexible("2024-01-15T14:30:00-05:00").is_some());
// Test ISO 8601 without timezone (should work as local time)
assert!(parse_datetime_flexible("2024-01-15T14:30:00").is_some());
// Test with milliseconds
assert!(parse_datetime_flexible("2024-01-15T14:30:00.000Z").is_some());
// Test space separated
assert!(parse_datetime_flexible("2024-01-15 14:30:00").is_some());
// Test date only
assert!(parse_datetime_flexible("2024-01-15").is_some());
// Test US format
assert!(parse_datetime_flexible("01/15/2024 14:30").is_some());
// Test invalid format
assert!(parse_datetime_flexible("invalid-date").is_none());
assert!(parse_datetime_flexible("").is_none());
}
#[test]
fn test_is_valid_datetime() {
assert!(is_valid_datetime("2025-06-28T23:00"));
assert!(is_valid_datetime("2024-01-15T14:30:00Z"));
assert!(!is_valid_datetime("invalid-date"));
assert!(!is_valid_datetime(""));
}
}