Compare commits
3 commits
7f711f7fbe
...
91a1bb7a54
Author | SHA1 | Date | |
---|---|---|---|
![]() |
91a1bb7a54 | ||
![]() |
1013ca0870 | ||
![]() |
f91f696334 |
25
.gitignore
vendored
25
.gitignore
vendored
|
@ -7,11 +7,16 @@ node_modules/
|
|||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
|
||||
# Astro build output
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
# Generated CSS from Tailwind builds
|
||||
public/css/theme-light.css
|
||||
public/css/theme-dark.css
|
||||
|
||||
# Compiled binaries and libraries
|
||||
*.node
|
||||
*.so
|
||||
|
@ -31,10 +36,21 @@ RTSDA/
|
|||
# Build outputs
|
||||
build/
|
||||
*.log
|
||||
index.cjs
|
||||
index.d.ts
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
|
@ -43,4 +59,11 @@ ehthumbs.db
|
|||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.temp
|
||||
|
||||
# Cargo build metadata
|
||||
.cargo/
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
*.lcov
|
BIN
.serena/cache/rust/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
BIN
.serena/cache/rust/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
Binary file not shown.
56
.serena/memories/project_overview.md
Normal file
56
.serena/memories/project_overview.md
Normal file
|
@ -0,0 +1,56 @@
|
|||
# RTSDA Web Project Overview
|
||||
|
||||
## Project Purpose
|
||||
This is a church website project for Rockville Tolland Seventh-day Adventist Church, featuring:
|
||||
- Event management and submission system
|
||||
- Sermon archive and streaming
|
||||
- Bulletin management
|
||||
- Contact forms
|
||||
- Admin interface
|
||||
|
||||
## Tech Stack
|
||||
- **Frontend**: Astro 5.13.0 with TailwindCSS
|
||||
- **Backend**: Rust-based church-core library with NAPI-RS bindings
|
||||
- **Architecture**: Hybrid - Astro frontend with Rust backend via native bindings
|
||||
- **API**: External API at `https://api.rockvilletollandsda.church/api/`
|
||||
|
||||
## Key Components
|
||||
- `astro-church-website/` - Main Astro frontend
|
||||
- `church-core/` - Rust backend library
|
||||
- `axum-church-website/` - Alternative backend (Axum-based)
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
/
|
||||
├── astro-church-website/ # Main Astro frontend
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── layouts/ # Page layouts
|
||||
│ │ ├── pages/ # Astro pages and API routes
|
||||
│ │ │ ├── api/ # API endpoints
|
||||
│ │ │ ├── events/ # Event-related pages
|
||||
│ │ │ ├── admin/ # Admin interface
|
||||
│ │ │ └── bulletin/ # Bulletin management
|
||||
│ │ └── lib/ # Utilities and bindings
|
||||
│ ├── package.json
|
||||
│ ├── Cargo.toml # Rust dependencies for NAPI
|
||||
│ └── build.rs # Build script
|
||||
├── church-core/ # Rust backend library
|
||||
└── axum-church-website/ # Alternative Axum backend
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
- `npm run dev` - Start development server (localhost:4321)
|
||||
- `npm run build` - Build production site (includes native build)
|
||||
- `npm run build:native` - Build native Rust bindings only
|
||||
- `npm run preview` - Preview built site
|
||||
|
||||
## Key Features
|
||||
1. **Event Submission Form** (`/events/submit`) - Users can submit events for approval
|
||||
2. **Admin Interface** (`/admin`) - Event management and approval
|
||||
3. **Bulletin Archive** (`/bulletin`) - Historical bulletins
|
||||
4. **Live Streaming** (`/live`) - Church service streaming
|
||||
5. **Sermon Archive** (`/sermons`) - Past sermons and audio
|
||||
|
||||
## Known Issue
|
||||
There's a database error when users select "2nd 3rd Saturday" in the recurring field of the event submission form, causing a 500 error.
|
71
.serena/memories/suggested_commands.md
Normal file
71
.serena/memories/suggested_commands.md
Normal file
|
@ -0,0 +1,71 @@
|
|||
# Suggested Development Commands
|
||||
|
||||
## Core Development Commands
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev # Starts at localhost:4321
|
||||
|
||||
# Build for production
|
||||
npm run build # Includes native Rust build
|
||||
npm run build:native # Build only Rust bindings
|
||||
npm run preview # Preview production build
|
||||
|
||||
# Astro CLI commands
|
||||
npm run astro ... # Run any Astro CLI command
|
||||
npm run astro -- --help # Get Astro CLI help
|
||||
```
|
||||
|
||||
## System Utilities (Darwin/macOS)
|
||||
```bash
|
||||
# Basic file operations
|
||||
ls # List files
|
||||
cd <directory> # Change directory
|
||||
find . -name "*.ts" # Find TypeScript files
|
||||
grep -r "pattern" . # Search for patterns
|
||||
|
||||
# Git operations
|
||||
git status # Check git status
|
||||
git add . # Stage all changes
|
||||
git commit -m "message" # Commit changes
|
||||
git push # Push to remote
|
||||
|
||||
# Package management
|
||||
npm install <package> # Install new package
|
||||
npm uninstall <package> # Remove package
|
||||
npm audit # Check for vulnerabilities
|
||||
```
|
||||
|
||||
## Rust/NAPI Commands
|
||||
```bash
|
||||
# Native bindings
|
||||
cargo build --release # Build Rust code
|
||||
napi build --platform --release # Build NAPI bindings
|
||||
|
||||
# Rust development
|
||||
cargo check # Check Rust code
|
||||
cargo test # Run Rust tests
|
||||
cargo clippy # Rust linter
|
||||
```
|
||||
|
||||
## Testing & Quality (when available)
|
||||
```bash
|
||||
# Check if these exist in project:
|
||||
npm run test # Run tests (if configured)
|
||||
npm run lint # Lint code (if configured)
|
||||
npm run format # Format code (if configured)
|
||||
npm run typecheck # TypeScript checking (if configured)
|
||||
```
|
||||
|
||||
## Debugging
|
||||
```bash
|
||||
# Development debugging
|
||||
npm run dev -- --verbose # Verbose development mode
|
||||
npm run build -- --verbose # Verbose build
|
||||
|
||||
# View logs
|
||||
tail -f logs/app.log # If logs exist
|
||||
console logs via browser dev tools # For frontend debugging
|
||||
```
|
52
.serena/memories/task_completion_checklist.md
Normal file
52
.serena/memories/task_completion_checklist.md
Normal file
|
@ -0,0 +1,52 @@
|
|||
# Task Completion Checklist
|
||||
|
||||
When completing any development task in this project, follow these steps:
|
||||
|
||||
## 1. Code Quality Checks
|
||||
- [ ] **TypeScript Validation**: Run `npx tsc --noEmit` to check TypeScript types
|
||||
- [ ] **Build Test**: Run `npm run build` to ensure the project builds successfully
|
||||
- [ ] **Development Server**: Test with `npm run dev` to ensure functionality works locally
|
||||
|
||||
## 2. Rust/Native Code (if applicable)
|
||||
- [ ] **Rust Check**: Run `cargo check` in the church-core directory
|
||||
- [ ] **Native Build**: Run `npm run build:native` to ensure NAPI bindings compile
|
||||
- [ ] **Cargo Clippy**: Run `cargo clippy` for Rust linting (if available)
|
||||
|
||||
## 3. Frontend Testing
|
||||
- [ ] **Manual Testing**: Test all affected functionality in the browser
|
||||
- [ ] **Cross-browser Check**: Test in different browsers if UI changes were made
|
||||
- [ ] **Mobile Responsiveness**: Check mobile layout if UI changes were made
|
||||
|
||||
## 4. API Integration
|
||||
- [ ] **API Endpoints**: Test any modified API routes (`/api/*`)
|
||||
- [ ] **External API**: Verify integration with `https://api.rockvilletollandsda.church/api/`
|
||||
- [ ] **Error Handling**: Ensure proper error responses and user feedback
|
||||
|
||||
## 5. Documentation
|
||||
- [ ] **Code Comments**: Add comments for complex logic
|
||||
- [ ] **README Updates**: Update documentation if new features or requirements
|
||||
- [ ] **Memory Updates**: Update project memory files if architecture changes
|
||||
|
||||
## 6. Git Best Practices
|
||||
- [ ] **Commit Messages**: Use clear, descriptive commit messages
|
||||
- [ ] **Branch Management**: Work on feature branches when appropriate
|
||||
- [ ] **Clean History**: Squash commits if multiple small fixes
|
||||
|
||||
## 7. Performance & Security
|
||||
- [ ] **Bundle Size**: Check if changes affect build size significantly
|
||||
- [ ] **Security**: Ensure no sensitive data is exposed
|
||||
- [ ] **Caching**: Verify API caching headers are appropriate
|
||||
|
||||
## Common Issues to Watch For
|
||||
- **NAPI Bindings**: Ensure native code compiles on target platforms
|
||||
- **External API Dependency**: Handle cases where external API is unavailable
|
||||
- **Image Uploads**: Validate file sizes and types properly
|
||||
- **Form Validation**: Both client-side and server-side validation
|
||||
- **Recurring Events**: Special attention to date/time handling
|
||||
|
||||
## Pre-deployment Checklist
|
||||
- [ ] All TypeScript errors resolved
|
||||
- [ ] Production build succeeds
|
||||
- [ ] All features tested manually
|
||||
- [ ] External dependencies verified
|
||||
- [ ] Environment variables documented (if new ones added)
|
68
.serena/project.yml
Normal file
68
.serena/project.yml
Normal file
|
@ -0,0 +1,68 @@
|
|||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: rust
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed)on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file or directory.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "rtsda-web-main"
|
104
CHANGES.md
Normal file
104
CHANGES.md
Normal file
|
@ -0,0 +1,104 @@
|
|||
# Admin Panel Architecture Conversion & Cleanup
|
||||
|
||||
## Summary
|
||||
Converted the 1800+ line vanilla JavaScript admin panel to proper Astro routes following CLAUDE.md architecture rules. Removed thumbnail field from event submission form as requested.
|
||||
|
||||
## Files Changed
|
||||
|
||||
### ✅ REMOVED (Architecture Violations)
|
||||
- `astro-church-website/public/admin/scripts/main.js` - 1843 lines of vanilla JS making direct API calls
|
||||
- `astro-church-website/public/admin/` - Entire directory removed
|
||||
|
||||
### ✅ ADDED (Proper Architecture)
|
||||
**New Admin Routes (Astro + TypeScript):**
|
||||
- `astro-church-website/src/pages/admin/events.astro` - Events management UI
|
||||
- `astro-church-website/src/pages/admin/bulletins.astro` - Bulletins management UI
|
||||
- `astro-church-website/src/pages/admin/api/events.ts` - Events API endpoints
|
||||
- `astro-church-website/src/pages/admin/api/events/[id].ts` - Event CRUD operations
|
||||
- `astro-church-website/src/pages/admin/api/events/[id]/approve.ts` - Event approval
|
||||
- `astro-church-website/src/pages/admin/api/events/[id]/reject.ts` - Event rejection
|
||||
- `astro-church-website/src/pages/admin/api/bulletins.ts` - Bulletins API endpoints
|
||||
- `astro-church-website/src/pages/admin/api/bulletins/[id].ts` - Bulletin CRUD operations
|
||||
- `astro-church-website/src/pages/admin/api/auth/login.ts` - Admin authentication
|
||||
|
||||
### ✅ ENHANCED (Rust Core Functions)
|
||||
**church-core/src/api.rs** - Added missing admin functions:
|
||||
- Auth: `admin_login_json()`, `validate_admin_token_json()`
|
||||
- Events: `fetch_pending_events_json()`, `approve_pending_event_json()`, `reject_pending_event_json()`, `delete_pending_event_json()`, `create_admin_event_json()`, `update_admin_event_json()`, `delete_admin_event_json()`
|
||||
- Bulletins: `create_bulletin_json()`, `update_bulletin_json()`, `delete_bulletin_json()`
|
||||
|
||||
**church-core/src/client/mod.rs** - Added auth methods:
|
||||
- `admin_login()` - Handles admin authentication
|
||||
- `validate_admin_token()` - Validates admin session tokens
|
||||
|
||||
### ✅ UPDATED (Bindings & Config)
|
||||
**astro-church-website/src/lib/bindings.js** - Added exports for new admin functions
|
||||
**astro-church-website/DEPLOYMENT.md** - Updated deployment instructions
|
||||
|
||||
### ✅ THUMBNAIL FIELD REMOVAL
|
||||
**astro-church-website/src/pages/events/submit.astro** - Removed:
|
||||
- Thumbnail upload section (HTML form elements)
|
||||
- Thumbnail JavaScript handling code
|
||||
- Thumbnail file processing in form submission
|
||||
|
||||
## Architecture Compliance Achieved
|
||||
|
||||
### ✅ BEFORE (Violations)
|
||||
```
|
||||
Frontend → fetch() → External API directly
|
||||
```
|
||||
|
||||
### ✅ AFTER (Correct)
|
||||
```
|
||||
Frontend (Astro) → bindings.js → Rust FFI → church-core → API
|
||||
```
|
||||
|
||||
## Results
|
||||
- **Removed 1843 lines** of architecture-violating vanilla JavaScript
|
||||
- **Added proper TypeScript Astro routes** following the architecture
|
||||
- **All admin functionality** now goes through the Rust core
|
||||
- **Build verified** - project compiles and runs successfully
|
||||
- **Bundle size reduced** - cleaner, more efficient code
|
||||
|
||||
## Next Steps Required
|
||||
|
||||
### 🔄 Additional Architecture Violations to Fix
|
||||
|
||||
1. **Event Submission Still Direct API** (`src/pages/events/submit.astro:748`)
|
||||
- Currently: `fetch('https://api.rockvilletollandsda.church/api/events/submit')`
|
||||
- Should: Use `submitEventJson()` from bindings
|
||||
|
||||
2. **Missing Admin Functions in Rust Core**
|
||||
- Admin stats/dashboard data
|
||||
- Configuration management (recurring types)
|
||||
- User management functions
|
||||
|
||||
3. **Data Model Mismatches** (from CLAUDE.md)
|
||||
- Frontend expects Schedule fields: `song_leader`, `childrens_story`
|
||||
- Check if Rust Schedule model has these fields
|
||||
|
||||
4. **Upload Functionality**
|
||||
- Current admin JS had image upload logic
|
||||
- Need to implement via Rust upload functions
|
||||
|
||||
### 🧹 Code Cleanup Opportunities
|
||||
|
||||
1. **Dead Code in Rust** (warnings from build)
|
||||
- `church-core/src/models/streaming.rs:1` - unused Serialize/Deserialize
|
||||
- `church-core/src/utils/formatting.rs:56` - unused FixedOffset
|
||||
- `church-core/src/client/http.rs:197` - unused delete method
|
||||
|
||||
2. **Validation Enhancement**
|
||||
- Add proper TypeScript types for admin API responses
|
||||
- Add client-side validation for admin forms
|
||||
|
||||
3. **Error Handling**
|
||||
- Replace generic `alert()` calls with proper error UI
|
||||
- Add loading states for admin operations
|
||||
|
||||
## Testing Required
|
||||
- [ ] Admin login functionality
|
||||
- [ ] Event approval/rejection workflow
|
||||
- [ ] Bulletin CRUD operations
|
||||
- [ ] Schedule management (existing)
|
||||
- [ ] Event submission without thumbnail field
|
123
CLAUDE.md
Normal file
123
CLAUDE.md
Normal file
|
@ -0,0 +1,123 @@
|
|||
# CLAUDE INSTRUCTIONS - RTSDA CODEBASE
|
||||
|
||||
## **CRITICAL ARCHITECTURE UNDERSTANDING**
|
||||
|
||||
### **ASTRO IS A THIN UI LAYER ONLY**
|
||||
- Astro handles routing, SSR, and basic UI rendering
|
||||
- **NO BUSINESS LOGIC IN FRONTEND**
|
||||
- **NO DIRECT API CALLS FROM FRONTEND**
|
||||
- **ALL LOGIC GOES THROUGH `church-core` RUST CRATE**
|
||||
|
||||
### **THE RUST CRATE IS THE CORE FOR A FUCKING REASON**
|
||||
- `church-core/` contains ALL business logic
|
||||
- `church-core/` handles ALL API communication
|
||||
- `church-core/` provides unified data models
|
||||
- `church-core/` has caching, error handling, auth
|
||||
- **USE THE RUST CRATE, DON'T BYPASS IT**
|
||||
|
||||
## ✅ **FIXED VIOLATIONS**
|
||||
|
||||
### **Admin Panel (COMPLETED ✅)**
|
||||
- ~~`public/admin/scripts/main.js` - 1800+ lines of vanilla JS~~ **REMOVED**
|
||||
- ✅ **NOW:** Proper Astro routes using Rust functions via FFI bindings
|
||||
- ✅ **FIXED:** Frontend now follows core architecture
|
||||
|
||||
## **REMAINING VIOLATIONS TO FIX**
|
||||
|
||||
### **Event Submission Direct API Call**
|
||||
- `src/pages/events/submit.astro:748` - Still uses direct fetch() call
|
||||
- **SHOULD BE:** Use `submitEventJson()` from bindings.js
|
||||
- **VIOLATION:** Bypassing the unified client architecture
|
||||
|
||||
### **Data Model Mismatches**
|
||||
- Frontend expects detailed Schedule fields (`song_leader`, `childrens_story`)
|
||||
- Backend Rust models missing these fields
|
||||
- **CAUSE:** Frontend developed independently of core models
|
||||
|
||||
|
||||
## **CORRECT ARCHITECTURE FLOW**
|
||||
|
||||
```
|
||||
Frontend (Astro) → bindings.js → Rust FFI → church-core → API
|
||||
```
|
||||
|
||||
**NOT:**
|
||||
```
|
||||
Frontend → fetch() → API directly
|
||||
```
|
||||
|
||||
## **HOW TO FIX VIOLATIONS**
|
||||
|
||||
### **For Event Submission:**
|
||||
1. ✅ Admin functions already exist in `church-core/src/client/admin.rs`
|
||||
2. ✅ Already exposed via FFI in `church-core/src/api.rs`
|
||||
3. ✅ Already imported in `astro-church-website/src/lib/bindings.js`
|
||||
4. **TODO:** Replace `fetch()` call with `submitEventJson()` function
|
||||
|
||||
### **For Data Models:**
|
||||
1. Update Schedule models in `church-core/src/models/admin.rs`
|
||||
2. Add missing fields (`song_leader`, `childrens_story`, etc.)
|
||||
3. Regenerate FFI bindings
|
||||
4. Update frontend to use new model structure
|
||||
|
||||
## **COMMANDS TO REMEMBER**
|
||||
|
||||
### **Build Commands:**
|
||||
```bash
|
||||
# Build Rust bindings FIRST
|
||||
npm run build:native
|
||||
|
||||
# Then build Astro
|
||||
npm run build
|
||||
|
||||
# Dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### **Testing Commands:**
|
||||
```bash
|
||||
# Test Rust core
|
||||
cd church-core && cargo test
|
||||
|
||||
# Test API connectivity
|
||||
cd church-core && cargo run --bin church-core-test
|
||||
```
|
||||
|
||||
## **BEFORE MAKING CHANGES**
|
||||
|
||||
1. **Check if Rust function exists** in `church-core/`
|
||||
2. **If missing, ADD IT TO RUST FIRST**
|
||||
3. **Then expose via FFI bindings**
|
||||
4. **Finally update frontend to use it**
|
||||
|
||||
## **DO NOT:**
|
||||
- Add business logic to JavaScript
|
||||
- Make direct API calls from frontend
|
||||
- Create data models in frontend
|
||||
- Bypass the Rust crate architecture
|
||||
|
||||
## **THE RUST CRATE EXISTS FOR:**
|
||||
- Cross-platform consistency (iOS, Android, Web)
|
||||
- Type safety and error handling
|
||||
- Unified caching and auth
|
||||
- Single source of truth for API communication
|
||||
|
||||
**RESPECT THE ARCHITECTURE. USE THE RUST CRATE.**
|
||||
|
||||
## **CLEANUP PROGRESS TRACKING**
|
||||
|
||||
### ✅ **COMPLETED (Aug 29, 2025)**
|
||||
- **Admin Panel Conversion**: Removed 1843-line vanilla JS file
|
||||
- **Proper Astro Routes**: Created TypeScript admin routes using Rust functions
|
||||
- **Thumbnail Field Removal**: Cleaned up event submission form
|
||||
- **FFI Functions**: Added 12 new admin functions in church-core
|
||||
- **Architecture Compliance**: Admin panel now follows correct flow
|
||||
|
||||
**Commit:** `f91f696` - Convert admin panel to Astro routes and remove thumbnail field
|
||||
|
||||
### 🚨 **HIGH PRIORITY NEXT**
|
||||
1. Fix event submission direct API call (`src/pages/events/submit.astro:748`)
|
||||
2. Check Schedule model data field mismatches
|
||||
3. Add missing admin functions (stats, config, users)
|
||||
|
||||
**See:** `/NEXT-STEPS.md` for detailed implementation plan
|
|
@ -9,7 +9,7 @@ crate-type = ["cdylib"]
|
|||
[dependencies]
|
||||
napi = { version = "2", default-features = false, features = ["napi4"] }
|
||||
napi-derive = "2"
|
||||
church-core = { path = "../church-core", features = ["uniffi"] }
|
||||
church-core = { git = "https://git.rockvilletollandsda.church/RTSDA/church-core.git" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
[build-dependencies]
|
|
@ -7,12 +7,12 @@
|
|||
src/pages/admin/index.astro # Main admin dashboard page
|
||||
src/components/admin/Login.astro # Admin login component
|
||||
src/pages/bulletin/[id].astro # Fixed bulletin detail page (SSR)
|
||||
public/admin/scripts/main.js # Admin JavaScript (if not already there)
|
||||
src/pages/admin/ # New Astro admin routes (TypeScript)
|
||||
```
|
||||
|
||||
### Verify these files exist on server:
|
||||
```
|
||||
public/admin/scripts/main.js # Admin functionality
|
||||
src/pages/admin/ # New admin routes using Rust bindings
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
|
@ -26,8 +26,8 @@ public/admin/scripts/main.js # Admin functionality
|
|||
# Copy fixed bulletin page
|
||||
scp src/pages/bulletin/[id].astro user@server:/opt/rtsda/src/pages/bulletin/
|
||||
|
||||
# Verify admin scripts exist
|
||||
scp public/admin/scripts/main.js user@server:/opt/rtsda/public/admin/scripts/
|
||||
# Copy new admin API routes
|
||||
scp -r src/pages/admin/api/ user@server:/opt/rtsda/src/pages/admin/
|
||||
```
|
||||
|
||||
2. **SSH into server:**
|
113
NEXT-STEPS.md
Normal file
113
NEXT-STEPS.md
Normal file
|
@ -0,0 +1,113 @@
|
|||
# Next Steps for Architecture Cleanup
|
||||
|
||||
## 🚨 Priority 1: Critical Architecture Violations
|
||||
|
||||
### 1. Fix Event Submission Direct API Call
|
||||
**Location:** `astro-church-website/src/pages/events/submit.astro:748`
|
||||
**Issue:** Still uses `fetch('https://api.rockvilletollandsda.church/api/events/submit')`
|
||||
**Fix:** Replace with `submitEventJson()` function from bindings
|
||||
|
||||
### 2. Data Model Mismatches
|
||||
**Issue:** Frontend expects Schedule fields that may not exist in Rust models
|
||||
**Check:** Does `church-core/src/models/admin.rs` Schedule struct have:
|
||||
- `song_leader`
|
||||
- `childrens_story`
|
||||
- `ss_teacher`
|
||||
- `ss_leader`
|
||||
- `mission_story`
|
||||
- `special_notes`
|
||||
|
||||
**Fix:** Update Rust Schedule model if fields are missing
|
||||
|
||||
## 🧹 Priority 2: Code Cleanup
|
||||
|
||||
### 1. Remove Dead Code (Rust Warnings)
|
||||
```bash
|
||||
# Fix these warnings from build output:
|
||||
cargo fix --lib -p church-core
|
||||
```
|
||||
**Locations:**
|
||||
- `church-core/src/models/streaming.rs:1` - unused Serialize/Deserialize
|
||||
- `church-core/src/utils/formatting.rs:56` - unused FixedOffset
|
||||
- `church-core/src/client/http.rs:197` - unused delete method
|
||||
|
||||
### 2. Missing Admin Functions
|
||||
**Add to `church-core/src/api.rs`:**
|
||||
- `fetch_admin_stats_json()` - Dashboard statistics
|
||||
- `fetch_recurring_types_json()` - Configuration data
|
||||
- `get_admin_users_json()` - User management
|
||||
|
||||
### 3. Upload Functionality
|
||||
**Issue:** Old admin JS had image upload logic not yet converted
|
||||
**Add to `church-core/src/api.rs`:**
|
||||
- `upload_event_image_json()`
|
||||
- `upload_bulletin_cover_json()`
|
||||
|
||||
## 🔄 Priority 3: Enhanced Admin Features
|
||||
|
||||
### 1. Add TypeScript Types
|
||||
**Create:** `astro-church-website/src/types/admin.ts`
|
||||
- Define admin API response types
|
||||
- Add type safety for admin operations
|
||||
|
||||
### 2. Improve Error Handling
|
||||
**Replace:** Generic `alert()` calls in admin pages
|
||||
**With:** Proper error UI components
|
||||
|
||||
### 3. Add Loading States
|
||||
**Add to admin pages:**
|
||||
- Loading spinners for operations
|
||||
- Disabled states during API calls
|
||||
- Better UX feedback
|
||||
|
||||
## 🎯 Priority 4: Testing & Validation
|
||||
|
||||
### 1. Test New Admin Functionality
|
||||
- [ ] Login/logout flow
|
||||
- [ ] Event approval/rejection
|
||||
- [ ] Bulletin CRUD operations
|
||||
- [ ] Schedule management
|
||||
- [ ] Event submission without thumbnail
|
||||
|
||||
### 2. Add Form Validation
|
||||
- [ ] Client-side validation for admin forms
|
||||
- [ ] Real-time feedback
|
||||
- [ ] Error message display
|
||||
|
||||
## 📝 Implementation Order
|
||||
|
||||
1. **Immediate (blocking):** Fix event submission API call
|
||||
2. **High:** Check/fix Schedule model data mismatches
|
||||
3. **Medium:** Add missing admin functions
|
||||
4. **Low:** UI/UX improvements and TypeScript types
|
||||
|
||||
## 🔧 Commands to Run
|
||||
|
||||
```bash
|
||||
# Test current state
|
||||
cd astro-church-website
|
||||
npm run build
|
||||
npm run dev
|
||||
|
||||
# Fix Rust warnings
|
||||
cd ../church-core
|
||||
cargo fix --lib -p church-core
|
||||
|
||||
# Rebuild after Rust changes
|
||||
cd ../astro-church-website
|
||||
npm run build:native
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🏆 Architecture Success Metrics
|
||||
|
||||
**✅ ACHIEVED:**
|
||||
- Removed 1843 lines of direct API call violations
|
||||
- All admin routes now use Rust core functions
|
||||
- Proper Astro/TypeScript structure implemented
|
||||
|
||||
**🎯 TARGET:**
|
||||
- Zero direct API calls from frontend
|
||||
- All business logic in Rust crate
|
||||
- Complete type safety with TypeScript
|
||||
- Full test coverage for admin operations
|
119
README.md
119
README.md
|
@ -1,104 +1,43 @@
|
|||
# RTSDA Website
|
||||
# Astro Starter Kit: Minimal
|
||||
|
||||
The official website for Rockville Tolland Seventh-day Adventist Church, built with Astro and powered by Rust bindings.
|
||||
|
||||
## Features
|
||||
|
||||
- **Modern Astro Framework** - Fast, component-based architecture
|
||||
- **Event Management** - Submit and manage church events with recurring types
|
||||
- **Live Streaming** - Watch services live with HLS.js support
|
||||
- **Mobile App Integration** - iOS and Android app download links
|
||||
- **Admin Panel** - Manage events, bulletins, and church content
|
||||
- **Three Angels' Message** - Dedicated sections for Adventist theology
|
||||
- **Rust-Powered Backend** - High-performance API bindings via `church-core`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
astro-church-website/ # Frontend Astro application
|
||||
├── src/
|
||||
│ ├── pages/ # Astro pages and API routes
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ └── layouts/ # Page layouts
|
||||
└── public/ # Static assets
|
||||
|
||||
church-core/ # Rust library for API bindings
|
||||
├── src/
|
||||
│ ├── client/ # API client implementations
|
||||
│ ├── models/ # Data structures
|
||||
│ └── uniffi_wrapper.rs # FFI bindings
|
||||
└── Cargo.toml
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
## Development
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
### Prerequisites
|
||||
## 🚀 Project Structure
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Rust 1.70+
|
||||
- Cargo
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
### Setup
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
cd astro-church-website
|
||||
npm install
|
||||
```
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
2. **Build Rust bindings:**
|
||||
```bash
|
||||
npm run build:native
|
||||
```
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
3. **Start development server:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
### Building for Production
|
||||
## 🧞 Commands
|
||||
|
||||
1. **Build native bindings:**
|
||||
```bash
|
||||
npm run build:native
|
||||
```
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
2. **Build Astro site:**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
3. **Deploy to web server:**
|
||||
```bash
|
||||
cp -r dist/* /var/www/rtsda-website/
|
||||
```
|
||||
## 👀 Want to learn more?
|
||||
|
||||
## Recent Fixes
|
||||
|
||||
- **SecondThirdSaturday Recurring Type** - Added support for `"2nd/3rd Saturday Monthly"` events
|
||||
- **Event Display Issues** - Fixed events page showing "No Events Scheduled"
|
||||
- **iOS App Compatibility** - Resolved recurring type parsing errors
|
||||
- **Mobile App Downloads** - Added iOS App Store and Android APK download buttons
|
||||
- **Session Management** - Improved admin panel authentication handling
|
||||
|
||||
## API Integration
|
||||
|
||||
The site integrates with the church API at `https://api.rockvilletollandsda.church` for:
|
||||
|
||||
- Events and recurring schedules
|
||||
- Sermon archives and live streams
|
||||
- Church bulletins and announcements
|
||||
- Contact form submissions
|
||||
- Admin authentication
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
See LICENSE file for details.
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
|
|
24
astro-church-website/.gitignore
vendored
24
astro-church-website/.gitignore
vendored
|
@ -1,24 +0,0 @@
|
|||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
|
@ -1,43 +0,0 @@
|
|||
# Astro Starter Kit: Minimal
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
|
@ -1,338 +0,0 @@
|
|||
/* 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, getChurchPhysicalAddress, getChurchPoBox, 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.getChurchPhysicalAddress = getChurchPhysicalAddress
|
||||
module.exports.getChurchPoBox = getChurchPoBox
|
||||
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
|
29
astro-church-website/index.d.ts
vendored
29
astro-church-website/index.d.ts
vendored
|
@ -1,29 +0,0 @@
|
|||
/* 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 getChurchPhysicalAddress(): string
|
||||
export declare function getChurchPoBox(): 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
|
6752
astro-church-website/package-lock.json
generated
6752
astro-church-website/package-lock.json
generated
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,484 +0,0 @@
|
|||
---
|
||||
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>
|
|
@ -1,113 +0,0 @@
|
|||
[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"
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
fn main() {
|
||||
#[cfg(feature = "uniffi")]
|
||||
{
|
||||
uniffi::generate_scaffolding("src/church_core.udl").unwrap();
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
// Authentication modules placeholder
|
||||
// This contains authentication implementations
|
||||
|
||||
pub use crate::models::AuthToken;
|
|
@ -1,36 +0,0 @@
|
|||
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),
|
||||
}
|
||||
}
|
|
@ -1,94 +0,0 @@
|
|||
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
339
church-core/src/cache/mod.rs
vendored
|
@ -1,339 +0,0 @@
|
|||
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
|
|
@ -1,78 +0,0 @@
|
|||
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();
|
||||
string get_church_physical_address();
|
||||
string get_church_po_box();
|
||||
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);
|
||||
};
|
|
@ -1,109 +0,0 @@
|
|||
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
|
||||
}
|
|
@ -1,169 +0,0 @@
|
|||
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)
|
||||
}
|
|
@ -1,101 +0,0 @@
|
|||
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),
|
||||
}
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
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
|
||||
}
|
|
@ -1,125 +0,0 @@
|
|||
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) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.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())))
|
||||
}
|
||||
}
|
|
@ -1,192 +0,0 @@
|
|||
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) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.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) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.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
|
||||
}
|
|
@ -1,402 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
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
|
||||
}
|
|
@ -1,412 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
|
@ -1,237 +0,0 @@
|
|||
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) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.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)
|
||||
}
|
|
@ -1,119 +0,0 @@
|
|||
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())
|
||||
))
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
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(_))
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
// 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,
|
||||
};
|
|
@ -1,24 +0,0 @@
|
|||
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");
|
|
@ -1,92 +0,0 @@
|
|||
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,
|
||||
}
|
|
@ -1,276 +0,0 @@
|
|||
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",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
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",
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,240 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
|
@ -1,344 +0,0 @@
|
|||
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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
|
@ -1,253 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
|
@ -1,339 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
|
@ -1,349 +0,0 @@
|
|||
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>,
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
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};
|
|
@ -1,376 +0,0 @@
|
|||
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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,157 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/// 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
|
@ -1,310 +0,0 @@
|
|||
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, ×tamp);
|
||||
|
||||
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, ×tamp);
|
||||
|
||||
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, ×tamp);
|
||||
|
||||
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, ×tamp);
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
|
@ -1,159 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
pub mod scripture;
|
||||
pub mod validation;
|
||||
pub mod formatting;
|
||||
pub mod feed;
|
||||
|
||||
pub use scripture::*;
|
||||
pub use validation::*;
|
||||
pub use formatting::*;
|
||||
pub use feed::*;
|
|
@ -1,164 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
|
@ -1,249 +0,0 @@
|
|||
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(""));
|
||||
}
|
||||
}
|
4163
cpp/church_core.cpp
Normal file
4163
cpp/church_core.cpp
Normal file
File diff suppressed because it is too large
Load diff
166
cpp/church_core.hpp
Normal file
166
cpp/church_core.hpp
Normal file
|
@ -0,0 +1,166 @@
|
|||
// This file was autogenerated by some hot garbage in the `uniffi-bindgen-react-native` crate.
|
||||
// Trust me, you don't want to mess with it!
|
||||
#pragma once
|
||||
#include <jsi/jsi.h>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <ReactCommon/CallInvoker.h>
|
||||
#include "UniffiCallInvoker.h"
|
||||
|
||||
namespace react = facebook::react;
|
||||
namespace jsi = facebook::jsi;
|
||||
|
||||
class NativeChurchCore : public jsi::HostObject {
|
||||
private:
|
||||
// For calling back into JS from Rust.
|
||||
std::shared_ptr<uniffi_runtime::UniffiCallInvoker> callInvoker;
|
||||
|
||||
protected:
|
||||
std::map<std::string,jsi::Value> props;
|
||||
jsi::Value cpp_uniffi_internal_fn_func_ffi__string_to_byte_length(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_internal_fn_func_ffi__string_to_arraybuffer(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_internal_fn_func_ffi__arraybuffer_to_string(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_create_calendar_event_data(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_create_sermon_share_items_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_device_supports_av1(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_extract_full_verse_text(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_extract_scripture_references_string(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_extract_stream_url_from_status(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_bible_verse_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_bulletins_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_cached_image_base64(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_config_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_current_bulletin_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_events_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_featured_events_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_live_stream_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_livestream_archive_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_random_bible_verse_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_scripture_verses_for_sermon_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_sermons_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_fetch_stream_status_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_filter_sermons_by_media_type(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_format_event_for_display_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_format_scripture_text_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_format_time_range_string(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_generate_home_feed_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_generate_verse_description(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_about_text(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_av1_streaming_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_brand_color(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_church_address(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_church_name(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_contact_email(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_contact_phone(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_coordinates(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_donation_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_facebook_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_hls_streaming_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_instagram_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_livestream_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_media_type_display_name(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_media_type_icon(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_mission_statement(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_optimal_streaming_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_stream_live_status(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_website_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_get_youtube_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_is_multi_day_event_check(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_parse_bible_verse_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_parse_bulletins_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_parse_calendar_event_data(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_parse_contact_result_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_parse_events_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_parse_sermons_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_submit_contact_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_submit_contact_v2_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_submit_contact_v2_json_legacy(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_validate_contact_form_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_validate_email_address(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_fn_func_validate_phone_number(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_create_calendar_event_data(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_create_sermon_share_items_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_device_supports_av1(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_extract_full_verse_text(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_extract_scripture_references_string(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_extract_stream_url_from_status(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_bible_verse_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_bulletins_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_cached_image_base64(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_config_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_current_bulletin_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_events_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_featured_events_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_live_stream_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_livestream_archive_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_random_bible_verse_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_scripture_verses_for_sermon_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_sermons_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_fetch_stream_status_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_filter_sermons_by_media_type(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_format_event_for_display_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_format_scripture_text_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_format_time_range_string(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_generate_home_feed_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_generate_verse_description(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_about_text(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_av1_streaming_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_brand_color(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_church_address(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_church_name(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_contact_email(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_contact_phone(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_coordinates(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_donation_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_facebook_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_hls_streaming_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_instagram_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_livestream_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_media_type_display_name(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_media_type_icon(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_mission_statement(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_optimal_streaming_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_stream_live_status(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_website_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_get_youtube_url(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_is_multi_day_event_check(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_parse_bible_verse_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_parse_bulletins_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_parse_calendar_event_data(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_parse_contact_result_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_parse_events_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_parse_sermons_from_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_submit_contact_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_submit_contact_v2_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_submit_contact_v2_json_legacy(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_validate_contact_form_json(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_validate_email_address(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_uniffi_church_core_checksum_func_validate_phone_number(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
jsi::Value cpp_ffi_church_core_uniffi_contract_version(jsi::Runtime& rt, const jsi::Value& thisVal, const jsi::Value* args, size_t count);
|
||||
|
||||
public:
|
||||
NativeChurchCore(jsi::Runtime &rt, std::shared_ptr<uniffi_runtime::UniffiCallInvoker> callInvoker);
|
||||
virtual ~NativeChurchCore();
|
||||
|
||||
/**
|
||||
* The entry point into the crate.
|
||||
*
|
||||
* React Native must call `NativeChurchCore.registerModule(rt, callInvoker)` before using
|
||||
* the Javascript interface.
|
||||
*/
|
||||
static void registerModule(jsi::Runtime &rt, std::shared_ptr<react::CallInvoker> callInvoker);
|
||||
|
||||
/**
|
||||
* Some cleanup into the crate goes here.
|
||||
*
|
||||
* Current implementation is empty, however, this is not guaranteed to always be the case.
|
||||
*
|
||||
* Clients should call `NativeChurchCore.unregisterModule(rt)` after final use where possible.
|
||||
*/
|
||||
static void unregisterModule(jsi::Runtime &rt);
|
||||
|
||||
virtual jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name);
|
||||
virtual void set(jsi::Runtime& rt,const jsi::PropNameID& name,const jsi::Value& value);
|
||||
virtual std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& rt);
|
||||
};
|
|
@ -310,7 +310,7 @@ if (!nativeBinding) {
|
|||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { getChurchName, fetchEventsJson, fetchFeaturedEventsJson, fetchSermonsJson, fetchConfigJson, getMissionStatement, fetchRandomBibleVerseJson, getStreamLiveStatus, getLivestreamUrl, getChurchAddress, getChurchPhysicalAddress, getChurchPoBox, getContactPhone, getContactEmail, getFacebookUrl, getYoutubeUrl, getInstagramUrl, submitContactV2Json, validateContactFormJson, fetchLivestreamArchiveJson, fetchBulletinsJson, fetchCurrentBulletinJson, fetchBibleVerseJson, submitEventJson } = nativeBinding
|
||||
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
|
||||
|
@ -322,8 +322,6 @@ module.exports.fetchRandomBibleVerseJson = fetchRandomBibleVerseJson
|
|||
module.exports.getStreamLiveStatus = getStreamLiveStatus
|
||||
module.exports.getLivestreamUrl = getLivestreamUrl
|
||||
module.exports.getChurchAddress = getChurchAddress
|
||||
module.exports.getChurchPhysicalAddress = getChurchPhysicalAddress
|
||||
module.exports.getChurchPoBox = getChurchPoBox
|
||||
module.exports.getContactPhone = getContactPhone
|
||||
module.exports.getContactEmail = getContactEmail
|
||||
module.exports.getFacebookUrl = getFacebookUrl
|
|
@ -1,11 +1,10 @@
|
|||
{
|
||||
"name": "astro-church-website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "npm run build:native && npm run build:themes && astro build",
|
||||
"build:native": "napi build --platform --release",
|
||||
"build:native": "napi build --platform --release --js index.cjs",
|
||||
"build:themes": "npm run build:theme-light && npm run build:theme-dark",
|
||||
"build:theme-light": "tailwindcss -c tailwind.light.config.mjs -i ./src/styles/theme-input.css -o ./public/css/theme-light.css --minify",
|
||||
"build:theme-dark": "tailwindcss -c tailwind.dark.config.mjs -i ./src/styles/theme-input.css -o ./public/css/theme-dark.css --minify",
|
||||
|
@ -25,7 +24,6 @@
|
|||
},
|
||||
"napi": {
|
||||
"name": "church-core-bindings",
|
||||
"moduleType": "cjs",
|
||||
"triples": {
|
||||
"defaults": true,
|
||||
"additional": [
|
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 332 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
41
src/layouts/AdminLayout.astro
Normal file
41
src/layouts/AdminLayout.astro
Normal file
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title} - Admin Dashboard</title>
|
||||
<link rel="stylesheet" href="/css/theme-light.css">
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<nav class="bg-blue-600 text-white p-4 shadow-md">
|
||||
<div class="container mx-auto flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold">Church Admin Dashboard</h1>
|
||||
<div class="space-x-4">
|
||||
<a href="/admin/" class="hover:underline">Dashboard</a>
|
||||
<a href="/admin/schedules" class="hover:underline">Schedules</a>
|
||||
<a href="/admin/events" class="hover:underline">Events</a>
|
||||
<a href="/admin/bulletins" class="hover:underline">Bulletins</a>
|
||||
<button id="logout-btn" class="bg-red-500 px-3 py-1 rounded hover:bg-red-600">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container mx-auto py-8 px-4">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.getElementById('logout-btn')?.addEventListener('click', () => {
|
||||
localStorage.removeItem('adminToken');
|
||||
window.location.href = '/admin/login';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,119 +1,120 @@
|
|||
use napi_derive::napi;
|
||||
use church_core;
|
||||
use church_core::api;
|
||||
|
||||
#[napi]
|
||||
pub fn get_church_name() -> String {
|
||||
church_core::get_church_name()
|
||||
api::get_church_name()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_events_json() -> String {
|
||||
church_core::fetch_events_json()
|
||||
api::fetch_events_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_featured_events_json() -> String {
|
||||
church_core::fetch_featured_events_json()
|
||||
api::fetch_featured_events_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_sermons_json() -> String {
|
||||
church_core::fetch_sermons_json()
|
||||
api::fetch_sermons_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_config_json() -> String {
|
||||
church_core::fetch_config_json()
|
||||
api::fetch_config_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_mission_statement() -> String {
|
||||
church_core::get_mission_statement()
|
||||
api::get_mission_statement()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_random_bible_verse_json() -> String {
|
||||
church_core::fetch_random_bible_verse_json()
|
||||
api::fetch_random_bible_verse_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_stream_live_status() -> bool {
|
||||
church_core::get_stream_live_status()
|
||||
api::get_stream_live_status()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_livestream_url() -> String {
|
||||
church_core::get_livestream_url()
|
||||
api::get_livestream_url()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_church_address() -> String {
|
||||
church_core::get_church_address()
|
||||
api::get_church_address()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_church_physical_address() -> String {
|
||||
church_core::get_church_physical_address()
|
||||
api::get_church_physical_address()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_church_po_box() -> String {
|
||||
church_core::get_church_po_box()
|
||||
api::get_church_po_box()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_contact_phone() -> String {
|
||||
church_core::get_contact_phone()
|
||||
api::get_contact_phone()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_contact_email() -> String {
|
||||
church_core::get_contact_email()
|
||||
api::get_contact_email()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_facebook_url() -> String {
|
||||
church_core::get_facebook_url()
|
||||
api::get_facebook_url()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_youtube_url() -> String {
|
||||
church_core::get_youtube_url()
|
||||
api::get_youtube_url()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_instagram_url() -> String {
|
||||
church_core::get_instagram_url()
|
||||
api::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)
|
||||
api::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)
|
||||
api::validate_contact_form_json(form_json)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_livestream_archive_json() -> String {
|
||||
church_core::fetch_livestream_archive_json()
|
||||
api::fetch_livestream_archive_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_bulletins_json() -> String {
|
||||
church_core::fetch_bulletins_json()
|
||||
api::fetch_bulletins_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_current_bulletin_json() -> String {
|
||||
church_core::fetch_current_bulletin_json()
|
||||
api::fetch_current_bulletin_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_bible_verse_json(query: String) -> String {
|
||||
church_core::fetch_bible_verse_json(query)
|
||||
api::fetch_bible_verse_json(query)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
|
@ -128,7 +129,7 @@ pub fn submit_event_json(
|
|||
recurring_type: Option<String>,
|
||||
submitter_email: Option<String>
|
||||
) -> String {
|
||||
church_core::submit_event_json(
|
||||
api::submit_event_json(
|
||||
title,
|
||||
description,
|
||||
start_time,
|
||||
|
@ -141,4 +142,28 @@ pub fn submit_event_json(
|
|||
)
|
||||
}
|
||||
|
||||
// Admin functions removed due to API changes
|
||||
// Admin functions
|
||||
#[napi]
|
||||
pub fn test_admin_function() -> String {
|
||||
"test".to_string()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_all_schedules_json() -> String {
|
||||
api::fetch_all_schedules_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn create_schedule_json(schedule_json: String) -> String {
|
||||
api::create_schedule_json(schedule_json)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn update_schedule_json(date: String, update_json: String) -> String {
|
||||
api::update_schedule_json(date, update_json)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn delete_schedule_json(date: String) -> String {
|
||||
api::delete_schedule_json(date)
|
||||
}
|
|
@ -29,5 +29,25 @@ export const {
|
|||
fetchBulletinsJson,
|
||||
fetchCurrentBulletinJson,
|
||||
fetchBibleVerseJson,
|
||||
submitEventJson
|
||||
submitEventJson,
|
||||
submitEventWithImageJson,
|
||||
// Admin functions
|
||||
fetchAllSchedulesJson,
|
||||
createScheduleJson,
|
||||
updateScheduleJson,
|
||||
deleteScheduleJson,
|
||||
// Admin auth
|
||||
adminLoginJson,
|
||||
validateAdminTokenJson,
|
||||
// Admin events
|
||||
fetchPendingEventsJson,
|
||||
approvePendingEventJson,
|
||||
rejectPendingEventJson,
|
||||
deletePendingEventJson,
|
||||
updateAdminEventJson,
|
||||
deleteAdminEventJson,
|
||||
// Admin bulletins
|
||||
createBulletinJson,
|
||||
updateBulletinJson,
|
||||
deleteBulletinJson
|
||||
} = nativeBindings;
|
41
src/pages/admin/api/auth/login.ts
Normal file
41
src/pages/admin/api/auth/login.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { adminLoginJson } from '../../../../lib/bindings.js';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Email and password are required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const result = adminLoginJson(email, password);
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
44
src/pages/admin/api/bulletins.ts
Normal file
44
src/pages/admin/api/bulletins.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { fetchBulletinsJson, createBulletinJson } from '../../../lib/bindings.js';
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const bulletinsJson = fetchBulletinsJson();
|
||||
const bulletins = JSON.parse(bulletinsJson);
|
||||
|
||||
return new Response(JSON.stringify(bulletins), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to fetch bulletins' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const bulletinData = await request.json();
|
||||
const result = createBulletinJson(JSON.stringify(bulletinData));
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to create bulletin' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
69
src/pages/admin/api/bulletins/[id].ts
Normal file
69
src/pages/admin/api/bulletins/[id].ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { updateBulletinJson, deleteBulletinJson } from '../../../../lib/bindings.js';
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Bulletin ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = await request.json();
|
||||
const result = updateBulletinJson(id, JSON.stringify(updateData));
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to update bulletin' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Bulletin ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = deleteBulletinJson(id);
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to delete bulletin' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
19
src/pages/admin/api/events.ts
Normal file
19
src/pages/admin/api/events.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { fetchPendingEventsJson } from '../../../lib/bindings.js';
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const eventsJson = fetchPendingEventsJson();
|
||||
const events = JSON.parse(eventsJson);
|
||||
|
||||
return new Response(JSON.stringify(events), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to fetch pending events' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
69
src/pages/admin/api/events/[id].ts
Normal file
69
src/pages/admin/api/events/[id].ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { updateAdminEventJson, deleteAdminEventJson } from '../../../../lib/bindings.js';
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Event ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = await request.json();
|
||||
const result = updateAdminEventJson(id, JSON.stringify(updateData));
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to update event' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Event ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = deleteAdminEventJson(id);
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to delete event' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
35
src/pages/admin/api/events/[id]/approve.ts
Normal file
35
src/pages/admin/api/events/[id]/approve.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { approvePendingEventJson } from '../../../../../lib/bindings.js';
|
||||
|
||||
export const POST: APIRoute = async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Event ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = approvePendingEventJson(id);
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to approve event' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
35
src/pages/admin/api/events/[id]/reject.ts
Normal file
35
src/pages/admin/api/events/[id]/reject.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { rejectPendingEventJson } from '../../../../../lib/bindings.js';
|
||||
|
||||
export const POST: APIRoute = async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Event ID required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = rejectPendingEventJson(id);
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to reject event' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
44
src/pages/admin/api/schedules.ts
Normal file
44
src/pages/admin/api/schedules.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { fetchAllSchedulesJson, createScheduleJson, deleteScheduleJson } from '../../../lib/bindings.js';
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const schedulesJson = fetchAllSchedulesJson();
|
||||
const schedules = JSON.parse(schedulesJson);
|
||||
|
||||
return new Response(JSON.stringify(schedules), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to fetch schedules' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const scheduleData = await request.json();
|
||||
const result = createScheduleJson(JSON.stringify(scheduleData));
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to create schedule' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
69
src/pages/admin/api/schedules/[date].ts
Normal file
69
src/pages/admin/api/schedules/[date].ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { updateScheduleJson, deleteScheduleJson } from '../../../../lib/bindings.js';
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const { date } = params;
|
||||
|
||||
if (!date) {
|
||||
return new Response(JSON.stringify({ error: 'Date parameter required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = await request.json();
|
||||
const result = updateScheduleJson(date, JSON.stringify(updateData));
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to update schedule' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const { date } = params;
|
||||
|
||||
if (!date) {
|
||||
return new Response(JSON.stringify({ error: 'Date parameter required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = deleteScheduleJson(date);
|
||||
const response = JSON.parse(result);
|
||||
|
||||
if (response.success) {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to delete schedule' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
173
src/pages/admin/bulletins.astro
Normal file
173
src/pages/admin/bulletins.astro
Normal file
|
@ -0,0 +1,173 @@
|
|||
---
|
||||
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||
import { fetchBulletinsJson } from '../../lib/bindings.js';
|
||||
|
||||
let bulletins = [];
|
||||
try {
|
||||
const bulletinsJson = fetchBulletinsJson();
|
||||
bulletins = JSON.parse(bulletinsJson);
|
||||
} catch (error) {
|
||||
console.error('Error fetching bulletins:', error);
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Bulletins">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Bulletin Management</h1>
|
||||
<button id="create-bulletin-btn" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
|
||||
Create New Bulletin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{bulletins.map((bulletin) => (
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{bulletin.title}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(bulletin.date).toLocaleDateString()}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${bulletin.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
|
||||
{bulletin.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
<button class="text-blue-600 hover:text-blue-900" onclick={`editBulletin('${bulletin.id}')`}>
|
||||
Edit
|
||||
</button>
|
||||
<button class="text-red-600 hover:text-red-900" onclick={`deleteBulletin('${bulletin.id}')`}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{bulletins.length === 0 && (
|
||||
<div class="text-center py-8">
|
||||
<p class="text-gray-500">No bulletins found. Create your first bulletin to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Create Bulletin Modal -->
|
||||
<div id="bulletin-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden">
|
||||
<div class="flex items-center justify-center min-h-screen px-4">
|
||||
<div class="bg-white rounded-lg max-w-2xl w-full p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 id="bulletin-modal-title" class="text-lg font-medium">Create New Bulletin</h2>
|
||||
<button id="close-bulletin-modal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="bulletin-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Title</label>
|
||||
<input type="text" name="title" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Date</label>
|
||||
<input type="date" name="date" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Content</label>
|
||||
<textarea name="content" rows="10" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="is_active" id="is_active" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">Active</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" id="cancel-bulletin-btn" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">Cancel</button>
|
||||
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Save Bulletin</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const bulletinModal = document.getElementById('bulletin-modal');
|
||||
const bulletinForm = document.getElementById('bulletin-form');
|
||||
|
||||
document.getElementById('create-bulletin-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('bulletin-modal-title').textContent = 'Create New Bulletin';
|
||||
bulletinForm.reset();
|
||||
bulletinModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('close-bulletin-modal')?.addEventListener('click', () => {
|
||||
bulletinModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('cancel-bulletin-btn')?.addEventListener('click', () => {
|
||||
bulletinModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
bulletinForm?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(bulletinForm);
|
||||
const bulletinData = Object.fromEntries(formData);
|
||||
bulletinData.is_active = bulletinData.is_active === 'on';
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/bulletins', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(bulletinData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error saving bulletin');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
window.editBulletin = (bulletinId) => {
|
||||
window.location.href = `/admin/bulletins/edit/${bulletinId}`;
|
||||
};
|
||||
|
||||
window.deleteBulletin = async (bulletinId) => {
|
||||
if (confirm('Are you sure you want to delete this bulletin?')) {
|
||||
try {
|
||||
const response = await fetch(`/admin/api/bulletins/${bulletinId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error deleting bulletin');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</AdminLayout>
|
138
src/pages/admin/events.astro
Normal file
138
src/pages/admin/events.astro
Normal file
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||
import { fetchPendingEventsJson, fetchEventsJson } from '../../lib/bindings.js';
|
||||
|
||||
let pendingEvents = [];
|
||||
let approvedEvents = [];
|
||||
try {
|
||||
const pendingJson = fetchPendingEventsJson();
|
||||
pendingEvents = JSON.parse(pendingJson);
|
||||
|
||||
const approvedJson = fetchEventsJson();
|
||||
approvedEvents = JSON.parse(approvedJson);
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Events">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Event Management</h1>
|
||||
<p class="text-gray-600 text-sm mt-1">Events must be created via the <a href="/events/submit" class="text-blue-600 hover:underline">public submission form</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Events Section -->
|
||||
<div class="bg-white rounded-lg shadow-md mb-6">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h2 class="text-lg font-semibold">Pending Events</h2>
|
||||
<p class="text-gray-600 text-sm">Events awaiting approval</p>
|
||||
</div>
|
||||
|
||||
{pendingEvents.length > 0 ? (
|
||||
<div class="overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Location</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Submitter</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{pendingEvents.map((event) => (
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{event.title}</div>
|
||||
<div class="text-sm text-gray-500">{event.description?.substring(0, 50)}...</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(event.start_time).toLocaleDateString()}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{event.location}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{event.submitter_email || 'Unknown'}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
<button class="bg-green-500 text-white px-3 py-1 rounded text-sm hover:bg-green-600" onclick={`approveEvent('${event.id}')`}>
|
||||
Approve
|
||||
</button>
|
||||
<button class="bg-red-500 text-white px-3 py-1 rounded text-sm hover:bg-red-600" onclick={`rejectEvent('${event.id}')`}>
|
||||
Reject
|
||||
</button>
|
||||
<button class="bg-gray-500 text-white px-3 py-1 rounded text-sm hover:bg-gray-600" onclick={`deleteEvent('${event.id}')`}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div class="text-center py-8">
|
||||
<p class="text-gray-500">No pending events found.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.approveEvent = async (eventId) => {
|
||||
if (confirm('Are you sure you want to approve this event?')) {
|
||||
try {
|
||||
const response = await fetch(`/admin/api/events/${eventId}/approve`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error approving event');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.rejectEvent = async (eventId) => {
|
||||
if (confirm('Are you sure you want to reject this event?')) {
|
||||
try {
|
||||
const response = await fetch(`/admin/api/events/${eventId}/reject`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error rejecting event');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.deleteEvent = async (eventId) => {
|
||||
if (confirm('Are you sure you want to delete this event?')) {
|
||||
try {
|
||||
const response = await fetch(`/admin/api/events/${eventId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error deleting event');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</AdminLayout>
|
104
src/pages/admin/index.astro
Normal file
104
src/pages/admin/index.astro
Normal file
|
@ -0,0 +1,104 @@
|
|||
---
|
||||
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||
import { fetchAllSchedulesJson } from '../../lib/bindings.js';
|
||||
|
||||
// Server-side: Get dashboard data using Rust functions
|
||||
let recentSchedules = [];
|
||||
try {
|
||||
const schedulesJson = fetchAllSchedulesJson();
|
||||
const allSchedules = JSON.parse(schedulesJson);
|
||||
// Get the 5 most recent schedules
|
||||
recentSchedules = allSchedules
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||
.slice(0, 5);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Dashboard">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||
<p class="text-gray-600">Welcome to the church administration panel</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 class="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||
<div class="space-y-3">
|
||||
<a href="/admin/schedules" class="block bg-blue-500 text-white text-center py-2 px-4 rounded hover:bg-blue-600">
|
||||
Manage Schedules
|
||||
</a>
|
||||
<a href="/admin/events" class="block bg-green-500 text-white text-center py-2 px-4 rounded hover:bg-green-600">
|
||||
Manage Events
|
||||
</a>
|
||||
<a href="/admin/bulletins" class="block bg-purple-500 text-white text-center py-2 px-4 rounded hover:bg-purple-600">
|
||||
Manage Bulletins
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Schedules -->
|
||||
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 class="text-lg font-semibold mb-4">Recent Schedules</h2>
|
||||
{recentSchedules.length > 0 ? (
|
||||
<div class="space-y-2">
|
||||
{recentSchedules.map((schedule) => (
|
||||
<div class="border-b pb-2">
|
||||
<div class="font-medium">{schedule.date}</div>
|
||||
<div class="text-sm text-gray-600">{schedule.divine_worship || 'No worship info'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p class="text-gray-500">No schedules found</p>
|
||||
)}
|
||||
<a href="/admin/schedules" class="text-blue-500 hover:underline text-sm mt-2 block">View all →</a>
|
||||
</div>
|
||||
|
||||
<!-- System Status -->
|
||||
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 class="text-lg font-semibold mb-4">System Status</h2>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span>API Connection</span>
|
||||
<span class="text-green-500">✓ Online</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Database</span>
|
||||
<span class="text-green-500">✓ Connected</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Cache</span>
|
||||
<span class="text-green-500">✓ Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Overview</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{recentSchedules.length}</div>
|
||||
<div class="text-sm text-gray-600">Recent Schedules</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">-</div>
|
||||
<div class="text-sm text-gray-600">Active Events</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-purple-600">-</div>
|
||||
<div class="text-sm text-gray-600">Published Bulletins</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-orange-600">-</div>
|
||||
<div class="text-sm text-gray-600">Pending Items</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
218
src/pages/admin/schedules.astro
Normal file
218
src/pages/admin/schedules.astro
Normal file
|
@ -0,0 +1,218 @@
|
|||
---
|
||||
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||
import { fetchAllSchedulesJson } from '../../lib/bindings.js';
|
||||
|
||||
// Server-side: Fetch schedules using Rust function
|
||||
let schedules = [];
|
||||
try {
|
||||
const schedulesJson = fetchAllSchedulesJson();
|
||||
schedules = JSON.parse(schedulesJson);
|
||||
} catch (error) {
|
||||
console.error('Error fetching schedules:', error);
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Schedules">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Schedule Management</h1>
|
||||
<button id="add-schedule-btn" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
|
||||
Add New Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Sabbath School</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Divine Worship</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Children's Story</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{schedules.map((schedule) => (
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{schedule.date}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{schedule.sabbath_school || '-'}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{schedule.divine_worship || '-'}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{schedule.childrens_story || '-'}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button class="text-indigo-600 hover:text-indigo-900 mr-3" onclick={`editSchedule('${schedule.date}')`}>
|
||||
Edit
|
||||
</button>
|
||||
<button class="text-red-600 hover:text-red-900" onclick={`deleteSchedule('${schedule.date}')`}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{schedules.length === 0 && (
|
||||
<div class="text-center py-8">
|
||||
<p class="text-gray-500">No schedules found. Create your first schedule to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Schedule Modal -->
|
||||
<div id="schedule-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden">
|
||||
<div class="flex items-center justify-center min-h-screen px-4">
|
||||
<div class="bg-white rounded-lg max-w-2xl w-full p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 id="modal-title" class="text-lg font-medium">Add New Schedule</h2>
|
||||
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="schedule-form" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Date</label>
|
||||
<input type="date" name="date" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Sunset</label>
|
||||
<input type="time" name="sunset" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Sabbath School Lesson</label>
|
||||
<input type="text" name="sabbath_school" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Divine Worship</label>
|
||||
<input type="text" name="divine_worship" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Children's Story</label>
|
||||
<input type="text" name="childrens_story" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Scripture Reading</label>
|
||||
<input type="text" name="scripture_reading" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- Personnel assignments -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Song Leader</label>
|
||||
<input type="text" name="song_leader" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">SS Teacher</label>
|
||||
<input type="text" name="ss_teacher" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">SS Leader</label>
|
||||
<input type="text" name="ss_leader" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Mission Story</label>
|
||||
<input type="text" name="mission_story" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Special Notes</label>
|
||||
<textarea name="special_notes" rows="3" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" id="cancel-btn" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">Cancel</button>
|
||||
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Save Schedule</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simple client-side interactions
|
||||
const modal = document.getElementById('schedule-modal');
|
||||
const form = document.getElementById('schedule-form');
|
||||
|
||||
document.getElementById('add-schedule-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('modal-title').textContent = 'Add New Schedule';
|
||||
form.reset();
|
||||
modal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('close-modal')?.addEventListener('click', () => {
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Form submission - redirect to API endpoint
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const scheduleData = Object.fromEntries(formData);
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/schedules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(scheduleData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload(); // Refresh to show new schedule
|
||||
} else {
|
||||
alert('Error saving schedule');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Edit and delete functions
|
||||
window.editSchedule = (date) => {
|
||||
// Implement edit functionality
|
||||
window.location.href = `/admin/schedules/edit/${date}`;
|
||||
};
|
||||
|
||||
window.deleteSchedule = async (date) => {
|
||||
if (confirm('Are you sure you want to delete this schedule?')) {
|
||||
try {
|
||||
const response = await fetch(`/admin/api/schedules/${date}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Error deleting schedule');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</AdminLayout>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue