Compare commits
18
Commits
v0.1.11
...
15c9528f47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15c9528f47 | ||
|
|
9ee5048a43 | ||
|
|
2d43600066 | ||
|
|
d6e6075469 | ||
|
|
289b1e8d37 | ||
|
|
c4823b7e64 | ||
|
|
f7a89b431d | ||
|
|
1bee7a7940 | ||
|
|
1bd3e17672 | ||
|
|
91ca486e64 | ||
|
|
2389bca42b | ||
|
|
520960d009 | ||
|
|
0cda791d44 | ||
|
|
a65488c304 | ||
|
|
4d9d0a894c | ||
|
|
fd1e78ba8c | ||
|
|
99e2cbc1f0 | ||
|
|
71f444b9aa |
@@ -0,0 +1,60 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Pet sitting web service for managing clients and bookings. The owner uses the site to:
|
||||
- Receive and manage client requests (leads) from the website
|
||||
- Schedule calls and visits with clients
|
||||
- Upload photos/videos of pets for remote viewing by clients (public media page via unique token)
|
||||
- Get Telegram notifications about new requests
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Language:** Rust (edition 2024)
|
||||
- **Web framework:** [Cot](https://github.com/cot-rs/cot) - Rust web framework (Django-like), local path `../cot/cot`
|
||||
- **Database:** PostgreSQL (via Cot ORM)
|
||||
- **Notifications:** Telegram Bot API
|
||||
|
||||
## Build & Run
|
||||
|
||||
```sh
|
||||
cargo build # build
|
||||
cargo run # run dev server at http://127.0.0.1:8000
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name
|
||||
cargo clippy # lint
|
||||
cargo fmt --check # check formatting
|
||||
cot migration make # generate migrations from model changes (requires cot-cli)
|
||||
```
|
||||
|
||||
Set `WEB_PETTING_DATABASE_URL` (or `DATABASE_URL`) before running the app or migrations. Example:
|
||||
|
||||
```sh
|
||||
WEB_PETTING_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/web_petting cargo run
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Monolithic Cot web app with a single PostgreSQL database.
|
||||
|
||||
- `src/main.rs` - project/app setup, router, config
|
||||
- `src/models.rs` - all database models (Lead, Client, Visit, Media, User, Setting)
|
||||
- `src/migrations.rs` - migration registry
|
||||
- `src/migrations/` - migration files
|
||||
|
||||
## Database Design Principles
|
||||
|
||||
- **Soft-delete everywhere:** records are never physically deleted, only status changes (e.g. `active` -> `archived`, `new` -> `rejected`). This ensures data can always be recovered.
|
||||
- **Status fields** are stored as `String` with enum-like values defined in `models.rs`.
|
||||
- **Foreign keys** use `cot::db::ForeignKey<T>` with `Restrict` on delete/update.
|
||||
|
||||
## Data Model
|
||||
|
||||
- **Lead** (`new`/`in_progress`/`converted`/`rejected`) - public form submission; links to Client when converted
|
||||
- **Client** (`active`/`archived`) - confirmed client with `media_token` for public media page
|
||||
- **Visit** (`scheduled`/`completed`/`cancelled`/`deleted`) - pet sitting session, belongs to Client and User
|
||||
- **Media** (`active`/`archived`) - photo/video, belongs to Client, optionally to Visit
|
||||
- **User** (`active`/`archived`) - admin accounts (supports multiple admins)
|
||||
- **Setting** - global key-value config (telegram_bot_token, telegram_chat_id, etc.)
|
||||
@@ -13,30 +13,36 @@ Pet sitting web service for managing clients and bookings. The owner uses the si
|
||||
## Tech Stack
|
||||
|
||||
- **Language:** Rust (edition 2024)
|
||||
- **Web framework:** [Cot](https://github.com/cot-rs/cot) — Rust web framework (Django-like), local path `../cot/cot`
|
||||
- **Database:** SQLite (via Cot ORM), file `db.sqlite3`
|
||||
- **Web framework:** [Cot](https://github.com/cot-rs/cot) - Rust web framework (Django-like), local path `../cot/cot`
|
||||
- **Database:** PostgreSQL (via Cot ORM)
|
||||
- **Notifications:** Telegram Bot API
|
||||
|
||||
## Build & Run
|
||||
|
||||
```sh
|
||||
cargo build # build
|
||||
cargo run # run dev server at http://127.0.0.1:8000
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name
|
||||
cargo clippy # lint
|
||||
cargo fmt --check # check formatting
|
||||
cot migration make # generate migrations from model changes (requires cot-cli)
|
||||
cargo build # build
|
||||
cargo run # run dev server at http://127.0.0.1:8000
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name
|
||||
cargo clippy # lint
|
||||
cargo fmt --check # check formatting
|
||||
cot migration make # generate migrations from model changes (requires cot-cli)
|
||||
```
|
||||
|
||||
Set `WEB_PETTING_DATABASE_URL` (or `DATABASE_URL`) before running the app or migrations. Example:
|
||||
|
||||
```sh
|
||||
WEB_PETTING_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/web_petting cargo run
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Monolithic Cot web app with a single SQLite database.
|
||||
Monolithic Cot web app with a single PostgreSQL database.
|
||||
|
||||
- `src/main.rs` — project/app setup, router, config
|
||||
- `src/models.rs` — all database models (Lead, Client, Visit, Media, User, Setting)
|
||||
- `src/migrations.rs` — migration registry (auto-generated by `cot migration make`)
|
||||
- `src/migrations/` — migration files (auto-generated)
|
||||
- `src/main.rs` - project/app setup, router, config
|
||||
- `src/models.rs` - all database models (Lead, Client, Visit, Media, User, Setting)
|
||||
- `src/migrations.rs` - migration registry
|
||||
- `src/migrations/` - migration files
|
||||
|
||||
## Database Design Principles
|
||||
|
||||
@@ -46,9 +52,9 @@ Monolithic Cot web app with a single SQLite database.
|
||||
|
||||
## Data Model
|
||||
|
||||
- **Lead** (`new`/`in_progress`/`converted`/`rejected`) — public form submission; links to Client when converted
|
||||
- **Client** (`active`/`archived`) — confirmed client with `media_token` for public media page
|
||||
- **Visit** (`scheduled`/`completed`/`cancelled`) — pet sitting session, belongs to Client
|
||||
- **Media** (`active`/`archived`) — photo/video, belongs to Client, optionally to Visit
|
||||
- **User** (`active`/`archived`) — admin accounts (supports multiple admins)
|
||||
- **Setting** — global key-value config (telegram_bot_token, telegram_chat_id, etc.)
|
||||
- **Lead** (`new`/`in_progress`/`converted`/`rejected`) - public form submission; links to Client when converted
|
||||
- **Client** (`active`/`archived`) - confirmed client with `media_token` for public media page
|
||||
- **Visit** (`scheduled`/`completed`/`cancelled`/`deleted`) - pet sitting session, belongs to Client and User
|
||||
- **Media** (`active`/`archived`) - photo/video, belongs to Client, optionally to Visit
|
||||
- **User** (`active`/`archived`) - admin accounts (supports multiple admins)
|
||||
- **Setting** - global key-value config (telegram_bot_token, telegram_chat_id, etc.)
|
||||
|
||||
Generated
+1581
-127
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -1,10 +1,11 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "0.1.11"
|
||||
version = "1.0.4"
|
||||
edition = "2024"
|
||||
default-run = "web-petting"
|
||||
|
||||
[dependencies]
|
||||
cot = { version = "0.6.0", features = ["sqlite"] }
|
||||
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json"] }
|
||||
chrono = "0.4"
|
||||
chrono-tz = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -14,7 +15,13 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
||||
serde_json = "1"
|
||||
multer = "3"
|
||||
futures = "0.3"
|
||||
tokio = { version = "1", features = ["fs"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
tokio = { version = "1", features = ["fs", "process", "rt-multi-thread"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
web-push-native = "0.5"
|
||||
async-trait = "0.1"
|
||||
aws-sdk-s3 = { version = "1", default-features = false, features = ["rustls", "rt-tokio"] }
|
||||
|
||||
+5
-2
@@ -4,12 +4,15 @@ WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
COPY src ./src
|
||||
COPY templates ./templates
|
||||
RUN cargo build --release
|
||||
RUN cargo build --release --bins
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y ca-certificates ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /data
|
||||
ENV WEB_PETTING_UPLOAD_DIR=/data/uploads
|
||||
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
||||
COPY --from=builder /app/target/release/migrate_uploads_to_r2 /usr/local/bin/migrate_uploads_to_r2
|
||||
COPY --from=builder /app/target/release/normalize_videos_to_r2 /usr/local/bin/normalize_videos_to_r2
|
||||
COPY static /app/static
|
||||
EXPOSE 3000
|
||||
CMD ["web-petting"]
|
||||
|
||||
+1093
-87
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use web_push_native::jwt_simple::algorithms::ES256KeyPair;
|
||||
use web_push_native::p256::SecretKey;
|
||||
use web_push_native::p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
|
||||
fn main() {
|
||||
let key_pair = ES256KeyPair::generate();
|
||||
let private = key_pair.to_bytes();
|
||||
let public = SecretKey::from_slice(&private)
|
||||
.expect("generated key must be valid")
|
||||
.public_key()
|
||||
.to_encoded_point(false);
|
||||
println!("VAPID private key (copy only the next line):");
|
||||
println!("{}", URL_SAFE_NO_PAD.encode(&private));
|
||||
println!("\nVAPID public key (copy only the next line):");
|
||||
println!("{}", URL_SAFE_NO_PAD.encode(public.as_bytes()));
|
||||
println!("\nVAPID subject:");
|
||||
println!("mailto:admin@example.com");
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
#[allow(dead_code)]
|
||||
#[path = "../models.rs"]
|
||||
mod models;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../uploads.rs"]
|
||||
mod uploads;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use cot::db::{Database, Model};
|
||||
use models::{Media, Testimonial};
|
||||
|
||||
fn database_url() -> String {
|
||||
std::env::var("WEB_PETTING_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5432/web_petting".to_string())
|
||||
}
|
||||
|
||||
async fn add_thumbnail(
|
||||
sources: &mut BTreeMap<String, bool>,
|
||||
db_path: &str,
|
||||
required: bool,
|
||||
) -> bool {
|
||||
if !uploads::supports_thumbnail(db_path) {
|
||||
return true;
|
||||
}
|
||||
match uploads::ensure_local_thumbnail(db_path).await {
|
||||
Ok(thumbnail_path) => {
|
||||
sources
|
||||
.entry(thumbnail_path)
|
||||
.and_modify(|is_required| *is_required |= required)
|
||||
.or_insert(required);
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Could not create thumbnail for {db_path}: {error}");
|
||||
!required
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_video_derivatives(
|
||||
sources: &mut BTreeMap<String, bool>,
|
||||
db_path: &str,
|
||||
required: bool,
|
||||
) -> bool {
|
||||
if !uploads::supports_video_preview(db_path) {
|
||||
return true;
|
||||
}
|
||||
match uploads::ensure_video_thumbnail(&uploads::Storage::Local, db_path).await {
|
||||
Ok(thumbnail_path) => {
|
||||
sources
|
||||
.entry(thumbnail_path)
|
||||
.and_modify(|is_required| *is_required |= required)
|
||||
.or_insert(required);
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Could not create video derivatives for {db_path}: {error}");
|
||||
!required
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let db = Database::new(database_url()).await?;
|
||||
let storage = uploads::Storage::load_configured_r2(&db).await?;
|
||||
let mut sources: BTreeMap<String, bool> = BTreeMap::new();
|
||||
let mut preparation_failed = false;
|
||||
|
||||
for media in Media::objects().all(&db).await? {
|
||||
let required = media.status == "active";
|
||||
sources
|
||||
.entry(media.file_path.clone())
|
||||
.and_modify(|is_required| *is_required |= required)
|
||||
.or_insert(required);
|
||||
match media.file_type.as_str() {
|
||||
"photo" if !add_thumbnail(&mut sources, &media.file_path, required).await => {
|
||||
preparation_failed = true;
|
||||
}
|
||||
"video" if !add_video_derivatives(&mut sources, &media.file_path, required).await => {
|
||||
preparation_failed = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for testimonial in Testimonial::objects().all(&db).await? {
|
||||
let Some(image_path) = testimonial.image_path else {
|
||||
continue;
|
||||
};
|
||||
let required = testimonial.status == "active";
|
||||
sources
|
||||
.entry(image_path.clone())
|
||||
.and_modify(|is_required| *is_required |= required)
|
||||
.or_insert(required);
|
||||
// Testimonial thumbnails are retained too, even though the landing page
|
||||
// currently displays the processed full-size image.
|
||||
let _ = add_thumbnail(&mut sources, &image_path, false).await;
|
||||
}
|
||||
|
||||
let mut uploaded = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
let mut missing = 0usize;
|
||||
let mut failed = 0usize;
|
||||
|
||||
for (db_path, required) in sources {
|
||||
let local_path = uploads::resolve_db_path(&db_path);
|
||||
match tokio::fs::try_exists(&local_path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
eprintln!("Missing local file {}", local_path.display());
|
||||
missing += 1;
|
||||
if required {
|
||||
failed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"Could not inspect local file {}: {error}",
|
||||
local_path.display()
|
||||
);
|
||||
missing += 1;
|
||||
if required {
|
||||
failed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if storage.exists(&db_path).await? {
|
||||
println!("Already in R2: {db_path}");
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match storage.upload_local_copy(&db_path, &local_path).await {
|
||||
Ok(()) => {
|
||||
println!("Uploaded: {db_path}");
|
||||
uploaded += 1;
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to upload {db_path}: {error}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.close().await?;
|
||||
println!(
|
||||
"Migration summary: uploaded={uploaded}, already_present={skipped}, missing_local={missing}, failed={failed}"
|
||||
);
|
||||
println!("Local files were not deleted.");
|
||||
|
||||
if preparation_failed || failed > 0 {
|
||||
return Err("R2 migration did not complete successfully".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build Tokio runtime");
|
||||
if let Err(error) = runtime.block_on(run()) {
|
||||
eprintln!("Migration failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#[allow(dead_code)]
|
||||
#[path = "../models.rs"]
|
||||
mod models;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../uploads.rs"]
|
||||
mod uploads;
|
||||
|
||||
use cot::db::{Database, Model};
|
||||
use models::Media;
|
||||
|
||||
fn database_url() -> String {
|
||||
std::env::var("WEB_PETTING_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5432/web_petting".to_string())
|
||||
}
|
||||
|
||||
async fn local_file_exists(path: &std::path::Path) -> Result<bool, std::io::Error> {
|
||||
tokio::fs::try_exists(path).await
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let db = Database::new(database_url()).await?;
|
||||
let r2 = uploads::Storage::load_configured_r2(&db).await?;
|
||||
let mut converted = 0usize;
|
||||
let mut already_normalized = 0usize;
|
||||
let mut missing_archived = 0usize;
|
||||
let mut failed = 0usize;
|
||||
|
||||
for mut media in Media::objects().all(&db).await? {
|
||||
if media.file_type != "video" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let media_id = media.id.unwrap();
|
||||
let original_db_path = media.file_path.clone();
|
||||
let target_db_path = uploads::normalized_video_db_path(&original_db_path);
|
||||
let target_thumbnail_db_path = uploads::thumbnail_db_path(&target_db_path);
|
||||
let target_in_r2 = r2.exists(&target_db_path).await?;
|
||||
let thumbnail_in_r2 = r2.exists(&target_thumbnail_db_path).await?;
|
||||
|
||||
if !target_in_r2 || !thumbnail_in_r2 {
|
||||
let source_path = uploads::resolve_db_path(&original_db_path);
|
||||
let target_path = uploads::resolve_db_path(&target_db_path);
|
||||
let target_thumbnail_path = uploads::resolve_db_path(&target_thumbnail_db_path);
|
||||
let source_exists = local_file_exists(&source_path).await?;
|
||||
let target_exists = local_file_exists(&target_path).await?;
|
||||
let target_thumbnail_exists = local_file_exists(&target_thumbnail_path).await?;
|
||||
|
||||
if !source_exists && !target_exists {
|
||||
eprintln!(
|
||||
"Missing PVC source for media {media_id}: {}",
|
||||
source_path.display()
|
||||
);
|
||||
if media.status == "active" {
|
||||
failed += 1;
|
||||
} else {
|
||||
missing_archived += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if original_db_path == target_db_path || target_exists {
|
||||
if !target_thumbnail_exists
|
||||
&& let Err(error) =
|
||||
uploads::ensure_video_thumbnail(&uploads::Storage::Local, &target_db_path)
|
||||
.await
|
||||
{
|
||||
eprintln!("Could not create preview for media {media_id}: {error}");
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if let Some(parent) = target_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
println!("Converting media {media_id}: {original_db_path}");
|
||||
if let Err(error) = uploads::create_compact_video_files(
|
||||
&source_path,
|
||||
&target_path,
|
||||
&target_thumbnail_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("Could not convert media {media_id}: {error}");
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if !target_in_r2 {
|
||||
r2.upload_local_copy(&target_db_path, &target_path).await?;
|
||||
println!("Uploaded compact video: {target_db_path}");
|
||||
}
|
||||
if !thumbnail_in_r2 {
|
||||
r2.upload_local_copy(&target_thumbnail_db_path, &target_thumbnail_path)
|
||||
.await?;
|
||||
println!("Uploaded video preview: {target_thumbnail_db_path}");
|
||||
}
|
||||
}
|
||||
|
||||
if original_db_path != target_db_path {
|
||||
// New objects are safely present before the old R2 keys are removed.
|
||||
// The source on the PVC is deliberately left untouched.
|
||||
r2.remove(&original_db_path).await?;
|
||||
let original_thumbnail = uploads::thumbnail_db_path(&original_db_path);
|
||||
if original_thumbnail != target_thumbnail_db_path {
|
||||
r2.remove(&original_thumbnail).await?;
|
||||
}
|
||||
media.file_path = target_db_path.clone();
|
||||
media.save(&db).await?;
|
||||
converted += 1;
|
||||
println!("Normalized media {media_id}: {target_db_path}");
|
||||
} else {
|
||||
already_normalized += 1;
|
||||
}
|
||||
}
|
||||
|
||||
db.close().await?;
|
||||
println!(
|
||||
"Video normalization summary: converted={converted}, already_normalized={already_normalized}, missing_archived={missing_archived}, failed={failed}"
|
||||
);
|
||||
println!("Original PVC files were not changed or deleted.");
|
||||
if failed > 0 {
|
||||
return Err("video normalization did not complete successfully".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build Tokio runtime");
|
||||
if let Err(error) = runtime.block_on(run()) {
|
||||
eprintln!("Video normalization failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
+182
@@ -103,8 +103,11 @@ pub struct Translations {
|
||||
pub clients_media_link: &'static str,
|
||||
pub clients_add_title: &'static str,
|
||||
pub clients_add_button: &'static str,
|
||||
pub clients_delete: &'static str,
|
||||
pub clients_delete_confirm: &'static str,
|
||||
pub client_status_active: &'static str,
|
||||
pub client_status_archived: &'static str,
|
||||
pub client_status_deleted: &'static str,
|
||||
|
||||
// Users
|
||||
pub users_title: &'static str,
|
||||
@@ -137,6 +140,40 @@ pub struct Translations {
|
||||
pub settings_seo_keywords: &'static str,
|
||||
pub settings_turnstile_site_key: &'static str,
|
||||
pub settings_turnstile_secret_key: &'static str,
|
||||
pub settings_oidc_issuer_url: &'static str,
|
||||
pub settings_oidc_client_id: &'static str,
|
||||
pub settings_oidc_client_secret: &'static str,
|
||||
pub settings_oidc_allowed_groups: &'static str,
|
||||
pub settings_auth_password_enabled: &'static str,
|
||||
pub settings_auth_sso_enabled: &'static str,
|
||||
pub settings_section_advanced: &'static str,
|
||||
pub settings_section_notifications: &'static str,
|
||||
pub settings_section_captcha: &'static str,
|
||||
pub settings_section_oidc: &'static str,
|
||||
pub settings_section_general: &'static str,
|
||||
pub settings_section_storage: &'static str,
|
||||
pub settings_r2_enabled: &'static str,
|
||||
pub settings_r2_help: &'static str,
|
||||
pub settings_r2_account_id: &'static str,
|
||||
pub settings_r2_bucket: &'static str,
|
||||
pub settings_r2_access_key_id: &'static str,
|
||||
pub settings_r2_secret_access_key: &'static str,
|
||||
pub settings_r2_secret_unchanged: &'static str,
|
||||
pub settings_r2_migration_help: &'static str,
|
||||
pub settings_r2_error_incomplete: &'static str,
|
||||
pub settings_client_notifications_enabled: &'static str,
|
||||
pub settings_client_notifications_help: &'static str,
|
||||
pub settings_vapid_public_key: &'static str,
|
||||
pub settings_vapid_private_key: &'static str,
|
||||
pub settings_vapid_subject: &'static str,
|
||||
pub settings_vapid_warning: &'static str,
|
||||
pub settings_vapid_generate: &'static str,
|
||||
pub settings_push_subscribers: &'static str,
|
||||
pub settings_push_no_subscribers: &'static str,
|
||||
pub settings_push_client: &'static str,
|
||||
pub settings_push_devices: &'static str,
|
||||
pub settings_push_language: &'static str,
|
||||
pub settings_push_updated: &'static str,
|
||||
pub landing_contact_label: &'static str,
|
||||
pub landing_pricing_title: &'static str,
|
||||
|
||||
@@ -151,6 +188,11 @@ pub struct Translations {
|
||||
pub login_title: &'static str,
|
||||
pub login_button: &'static str,
|
||||
pub login_error: &'static str,
|
||||
pub login_sso_button: &'static str,
|
||||
pub login_sso_error: &'static str,
|
||||
pub login_sso_error_group: &'static str,
|
||||
pub login_sso_error_provider: &'static str,
|
||||
pub login_sso_error_user_disabled: &'static str,
|
||||
pub logout: &'static str,
|
||||
pub setup_title: &'static str,
|
||||
pub setup_description: &'static str,
|
||||
@@ -240,6 +282,7 @@ pub struct Translations {
|
||||
pub visit_status_scheduled: &'static str,
|
||||
pub visit_status_completed: &'static str,
|
||||
pub visit_status_cancelled: &'static str,
|
||||
pub visit_status_deleted: &'static str,
|
||||
pub schedule_mark_done: &'static str,
|
||||
pub schedule_cancel: &'static str,
|
||||
pub schedule_edit_title: &'static str,
|
||||
@@ -260,6 +303,11 @@ pub struct Translations {
|
||||
pub media_delete: &'static str,
|
||||
pub media_delete_confirm: &'static str,
|
||||
pub media_all_clients: &'static str,
|
||||
pub media_files_selected: &'static str,
|
||||
pub media_upload_sending: &'static str,
|
||||
pub media_upload_processing: &'static str,
|
||||
pub media_upload_done: &'static str,
|
||||
pub media_upload_connection_error: &'static str,
|
||||
|
||||
// Client portal
|
||||
pub portal_title: &'static str,
|
||||
@@ -272,6 +320,18 @@ pub struct Translations {
|
||||
pub portal_feedback_submit: &'static str,
|
||||
pub portal_feedback_thanks: &'static str,
|
||||
pub portal_link: &'static str,
|
||||
pub portal_notifications: &'static str,
|
||||
pub portal_notifications_text: &'static str,
|
||||
pub portal_notifications_enable: &'static str,
|
||||
pub portal_notifications_disable: &'static str,
|
||||
pub portal_notifications_denied: &'static str,
|
||||
pub portal_notifications_active: &'static str,
|
||||
pub portal_notifications_error: &'static str,
|
||||
pub portal_notifications_unsupported: &'static str,
|
||||
pub portal_calendar: &'static str,
|
||||
pub portal_future_visit: &'static str,
|
||||
pub portal_previous: &'static str,
|
||||
pub portal_next: &'static str,
|
||||
|
||||
// Common
|
||||
pub no_value: &'static str,
|
||||
@@ -320,8 +380,11 @@ static RU: Translations = Translations {
|
||||
clients_media_link: "Медиа",
|
||||
clients_add_title: "Добавить клиента",
|
||||
clients_add_button: "Добавить",
|
||||
clients_delete: "Удалить клиента",
|
||||
clients_delete_confirm: "Точно удалить этого клиента?",
|
||||
client_status_active: "Активный",
|
||||
client_status_archived: "Архив",
|
||||
client_status_deleted: "Удалён",
|
||||
|
||||
users_title: "Администраторы",
|
||||
users_login: "Логин",
|
||||
@@ -352,6 +415,40 @@ static RU: Translations = Translations {
|
||||
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
|
||||
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key (ключ виджета)",
|
||||
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key (секретный ключ)",
|
||||
settings_oidc_issuer_url: "OIDC — URL провайдера (Issuer URL)",
|
||||
settings_oidc_client_id: "OIDC — Client ID",
|
||||
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||
settings_oidc_allowed_groups: "OIDC — Разрешённые группы (через запятую, пусто = все)",
|
||||
settings_auth_password_enabled: "Вход по логину и паролю",
|
||||
settings_auth_sso_enabled: "Вход через SSO (OIDC)",
|
||||
settings_section_advanced: "Расширенные настройки",
|
||||
settings_section_notifications: "Уведомления",
|
||||
settings_section_captcha: "Защита от ботов",
|
||||
settings_section_oidc: "Единый вход (SSO / OIDC)",
|
||||
settings_section_general: "Сайт",
|
||||
settings_section_storage: "Хранилище медиа",
|
||||
settings_r2_enabled: "Использовать Cloudflare R2",
|
||||
settings_r2_help: "Пока R2 выключен или настроен не полностью, медиа хранится локально. После включения новые файлы и миниатюры загружаются в приватный бакет, а страницы получают подписанные ссылки на 6 часов.",
|
||||
settings_r2_account_id: "Cloudflare Account ID",
|
||||
settings_r2_bucket: "Имя R2-бакета",
|
||||
settings_r2_access_key_id: "R2 Access Key ID",
|
||||
settings_r2_secret_access_key: "R2 Secret Access Key",
|
||||
settings_r2_secret_unchanged: "Секрет уже сохранён; оставьте поле пустым, чтобы не менять его",
|
||||
settings_r2_migration_help: "Порядок перехода: сохраните реквизиты с выключенным R2, выполните в контейнере migrate_uploads_to_r2 и normalize_videos_to_r2, затем включите R2 и отключите PVC. Для бакета разрешите CORS GET/HEAD с домена сайта и заголовок Range.",
|
||||
settings_r2_error_incomplete: "R2 не включён: проверьте Account ID, имя бакета, Access Key ID и Secret Access Key.",
|
||||
settings_client_notifications_enabled: "Разрешить клиентам браузерные уведомления",
|
||||
settings_client_notifications_help: "Показывает клиентам настройку уведомлений о завершённых визитах.",
|
||||
settings_vapid_public_key: "VAPID — публичный ключ",
|
||||
settings_vapid_private_key: "VAPID — приватный ключ",
|
||||
settings_vapid_subject: "VAPID — контакт (например mailto:admin@example.com)",
|
||||
settings_vapid_warning: "Важно: смена пары VAPID-ключей сделает недействительными подписки всех клиентов. Им потребуется снова включить уведомления.",
|
||||
settings_vapid_generate: "Для генерации новой пары ключей выполните:",
|
||||
settings_push_subscribers: "Активные подписки клиентов",
|
||||
settings_push_no_subscribers: "Активных подписок пока нет.",
|
||||
settings_push_client: "Клиент",
|
||||
settings_push_devices: "Устройства",
|
||||
settings_push_language: "Язык",
|
||||
settings_push_updated: "Обновлено",
|
||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||
landing_pricing_title: "Стоимость",
|
||||
|
||||
@@ -371,6 +468,11 @@ static RU: Translations = Translations {
|
||||
media_delete: "Удалить",
|
||||
media_delete_confirm: "Удалить этот файл?",
|
||||
media_all_clients: "Все клиенты",
|
||||
media_files_selected: "Выбрано файлов",
|
||||
media_upload_sending: "Загрузка на сервер...",
|
||||
media_upload_processing: "Конвертация и загрузка в R2...",
|
||||
media_upload_done: "Готово — обновляем медиагалерею...",
|
||||
media_upload_connection_error: "Ошибка соединения",
|
||||
|
||||
portal_title: "Визиты",
|
||||
portal_upcoming: "Предстоящие визиты",
|
||||
@@ -382,10 +484,27 @@ static RU: Translations = Translations {
|
||||
portal_feedback_submit: "Отправить",
|
||||
portal_feedback_thanks: "Спасибо за отзыв!",
|
||||
portal_link: "Ссылка клиента",
|
||||
portal_notifications: "Уведомления",
|
||||
portal_notifications_text: "Получайте уведомления о завершённых визитах, даже когда страница закрыта. На iPhone сначала добавьте сайт на экран «Домой» и откройте его оттуда.",
|
||||
portal_notifications_enable: "Включить уведомления",
|
||||
portal_notifications_disable: "Отключить уведомления",
|
||||
portal_notifications_denied: "Уведомления заблокированы в настройках браузера.",
|
||||
portal_notifications_active: "Уведомления подключены на этом устройстве.",
|
||||
portal_notifications_error: "Не удалось сохранить подписку. Обновите страницу и попробуйте ещё раз.",
|
||||
portal_notifications_unsupported: "Этот браузер не поддерживает фоновые уведомления.",
|
||||
portal_calendar: "Календарь визитов",
|
||||
portal_future_visit: "Будущий визит",
|
||||
portal_previous: "Назад",
|
||||
portal_next: "Далее",
|
||||
|
||||
login_title: "Вход в систему",
|
||||
login_button: "Войти",
|
||||
login_error: "Неверный логин или пароль.",
|
||||
login_sso_button: "Войти через SSO",
|
||||
login_sso_error: "Ошибка SSO-авторизации.",
|
||||
login_sso_error_group: "У вас нет доступа: вы не состоите в разрешённой группе.",
|
||||
login_sso_error_provider: "Не удалось связаться с провайдером авторизации.",
|
||||
login_sso_error_user_disabled: "Ваша учётная запись отключена.",
|
||||
logout: "Выйти",
|
||||
setup_title: "Создание администратора",
|
||||
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
||||
@@ -421,6 +540,7 @@ static RU: Translations = Translations {
|
||||
visit_status_scheduled: "Запланирован",
|
||||
visit_status_completed: "Выполнен",
|
||||
visit_status_cancelled: "Отменён",
|
||||
visit_status_deleted: "Удалён",
|
||||
schedule_mark_done: "Выполнен",
|
||||
schedule_cancel: "Отменить",
|
||||
schedule_edit_title: "Редактировать визит",
|
||||
@@ -525,8 +645,11 @@ static EN: Translations = Translations {
|
||||
clients_media_link: "Media",
|
||||
clients_add_title: "Add Client",
|
||||
clients_add_button: "Add",
|
||||
clients_delete: "Delete client",
|
||||
clients_delete_confirm: "Are you sure you want to delete this client?",
|
||||
client_status_active: "Active",
|
||||
client_status_archived: "Archived",
|
||||
client_status_deleted: "Deleted",
|
||||
|
||||
users_title: "Administrators",
|
||||
users_login: "Login",
|
||||
@@ -557,6 +680,40 @@ static EN: Translations = Translations {
|
||||
settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)",
|
||||
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key",
|
||||
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key",
|
||||
settings_oidc_issuer_url: "OIDC — Issuer URL",
|
||||
settings_oidc_client_id: "OIDC — Client ID",
|
||||
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||
settings_oidc_allowed_groups: "OIDC — Allowed groups (comma-separated, empty = all)",
|
||||
settings_auth_password_enabled: "Password login",
|
||||
settings_auth_sso_enabled: "SSO login (OIDC)",
|
||||
settings_section_advanced: "Advanced settings",
|
||||
settings_section_notifications: "Notifications",
|
||||
settings_section_captcha: "Bot protection",
|
||||
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
|
||||
settings_section_general: "Site",
|
||||
settings_section_storage: "Media storage",
|
||||
settings_r2_enabled: "Use Cloudflare R2",
|
||||
settings_r2_help: "While R2 is disabled or incomplete, media stays in local storage. Once enabled, new files and thumbnails are uploaded to the private bucket and pages receive signed URLs valid for 6 hours.",
|
||||
settings_r2_account_id: "Cloudflare Account ID",
|
||||
settings_r2_bucket: "R2 bucket name",
|
||||
settings_r2_access_key_id: "R2 Access Key ID",
|
||||
settings_r2_secret_access_key: "R2 Secret Access Key",
|
||||
settings_r2_secret_unchanged: "A secret is already stored; leave this blank to keep it unchanged",
|
||||
settings_r2_migration_help: "Migration order: save the credentials with R2 disabled, run migrate_uploads_to_r2 and normalize_videos_to_r2 inside the container, then enable R2 and detach the PVC. Allow CORS GET/HEAD from the site domain and the Range header on the bucket.",
|
||||
settings_r2_error_incomplete: "R2 was not enabled: check the Account ID, bucket name, Access Key ID, and Secret Access Key.",
|
||||
settings_client_notifications_enabled: "Allow client browser notifications",
|
||||
settings_client_notifications_help: "Shows clients the completed-visit notification setting.",
|
||||
settings_vapid_public_key: "VAPID public key",
|
||||
settings_vapid_private_key: "VAPID private key",
|
||||
settings_vapid_subject: "VAPID contact (for example mailto:admin@example.com)",
|
||||
settings_vapid_warning: "Important: changing the VAPID key pair invalidates every client subscription. Clients will need to enable notifications again.",
|
||||
settings_vapid_generate: "To generate a new key pair, run:",
|
||||
settings_push_subscribers: "Active client subscriptions",
|
||||
settings_push_no_subscribers: "There are no active subscriptions yet.",
|
||||
settings_push_client: "Client",
|
||||
settings_push_devices: "Devices",
|
||||
settings_push_language: "Language",
|
||||
settings_push_updated: "Updated",
|
||||
landing_contact_label: "Or contact us directly",
|
||||
landing_pricing_title: "Pricing",
|
||||
|
||||
@@ -576,6 +733,11 @@ static EN: Translations = Translations {
|
||||
media_delete: "Delete",
|
||||
media_delete_confirm: "Delete this file?",
|
||||
media_all_clients: "All clients",
|
||||
media_files_selected: "Files selected",
|
||||
media_upload_sending: "Uploading to the server...",
|
||||
media_upload_processing: "Converting and uploading to R2...",
|
||||
media_upload_done: "Done — refreshing the media gallery...",
|
||||
media_upload_connection_error: "Connection error",
|
||||
|
||||
portal_title: "Visits",
|
||||
portal_upcoming: "Upcoming visits",
|
||||
@@ -587,10 +749,27 @@ static EN: Translations = Translations {
|
||||
portal_feedback_submit: "Submit",
|
||||
portal_feedback_thanks: "Thank you for your feedback!",
|
||||
portal_link: "Client link",
|
||||
portal_notifications: "Notifications",
|
||||
portal_notifications_text: "Receive completed-visit notifications even when this page is closed. On iPhone, first add this site to the Home Screen and open it from there.",
|
||||
portal_notifications_enable: "Enable notifications",
|
||||
portal_notifications_disable: "Disable notifications",
|
||||
portal_notifications_denied: "Notifications are blocked in your browser settings.",
|
||||
portal_notifications_active: "Notifications are enabled on this device.",
|
||||
portal_notifications_error: "The subscription could not be saved. Reload the page and try again.",
|
||||
portal_notifications_unsupported: "This browser does not support background notifications.",
|
||||
portal_calendar: "Visit calendar",
|
||||
portal_future_visit: "Future visit",
|
||||
portal_previous: "Previous",
|
||||
portal_next: "Next",
|
||||
|
||||
login_title: "Sign In",
|
||||
login_button: "Sign In",
|
||||
login_error: "Invalid login or password.",
|
||||
login_sso_button: "Sign in with SSO",
|
||||
login_sso_error: "SSO authentication failed.",
|
||||
login_sso_error_group: "Access denied: you are not a member of an allowed group.",
|
||||
login_sso_error_provider: "Could not reach the authentication provider.",
|
||||
login_sso_error_user_disabled: "Your account is disabled.",
|
||||
logout: "Sign Out",
|
||||
setup_title: "Create Administrator",
|
||||
setup_description: "There are no administrators yet. Create the first one to get started.",
|
||||
@@ -626,6 +805,7 @@ static EN: Translations = Translations {
|
||||
visit_status_scheduled: "Scheduled",
|
||||
visit_status_completed: "Completed",
|
||||
visit_status_cancelled: "Cancelled",
|
||||
visit_status_deleted: "Deleted",
|
||||
schedule_mark_done: "Done",
|
||||
schedule_cancel: "Cancel",
|
||||
schedule_edit_title: "Edit Visit",
|
||||
@@ -712,6 +892,7 @@ impl Translations {
|
||||
"scheduled" => self.visit_status_scheduled,
|
||||
"completed" => self.visit_status_completed,
|
||||
"cancelled" => self.visit_status_cancelled,
|
||||
"deleted" => self.visit_status_deleted,
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
@@ -720,6 +901,7 @@ impl Translations {
|
||||
match status {
|
||||
"active" => self.client_status_active,
|
||||
"archived" => self.client_status_archived,
|
||||
"deleted" => self.client_status_deleted,
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
+71
-18
@@ -6,17 +6,17 @@ mod public;
|
||||
mod telegram;
|
||||
mod turnstile;
|
||||
mod tz;
|
||||
|
||||
use tracing_subscriber;
|
||||
mod uploads;
|
||||
mod web_push;
|
||||
|
||||
use cot::cli::CliMetadata;
|
||||
use cot::config::{
|
||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
|
||||
SessionStoreTypeConfig,
|
||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
|
||||
SessionStoreConfig, SessionStoreTypeConfig,
|
||||
};
|
||||
use cot::db::migrations::SyncDynMigration;
|
||||
use cot::middleware::SessionMiddleware;
|
||||
use cot::project::{MiddlewareContext, RegisterAppsContext, RootHandler};
|
||||
use cot::project::{MiddlewareContext, ProjectContext, RegisterAppsContext, RootHandler};
|
||||
use cot::router::Router;
|
||||
use cot::session::db::SessionApp;
|
||||
use cot::{App, AppBuilder, Project};
|
||||
@@ -39,11 +39,17 @@ impl App for PettingApp {
|
||||
|
||||
struct PublicApp;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl App for PublicApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"public"
|
||||
}
|
||||
|
||||
async fn init(&self, context: &mut ProjectContext) -> cot::Result<()> {
|
||||
web_push::initialize(context.database()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
public::public_router()
|
||||
}
|
||||
@@ -51,24 +57,51 @@ impl App for PublicApp {
|
||||
|
||||
struct PettingProject;
|
||||
|
||||
fn parse_bool_env(name: &str) -> Option<bool> {
|
||||
let value = std::env::var(name).ok()?;
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn debug_enabled(config_name: &str) -> bool {
|
||||
parse_bool_env("WEB_PETTING_DEBUG").unwrap_or_else(|| {
|
||||
matches!(
|
||||
config_name,
|
||||
"dev" | "development" | "debug" | "local" | "test"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn database_url() -> String {
|
||||
std::env::var("WEB_PETTING_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| {
|
||||
eprintln!(
|
||||
"WEB_PETTING_DATABASE_URL and DATABASE_URL are not set; using the local default \
|
||||
postgresql://postgres:postgres@localhost:5432/web_petting"
|
||||
);
|
||||
"postgresql://postgres:postgres@localhost:5432/web_petting".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
impl Project for PettingProject {
|
||||
fn cli_metadata(&self) -> CliMetadata {
|
||||
cot::cli::metadata!()
|
||||
}
|
||||
|
||||
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
|
||||
fn config(&self, config_name: &str) -> cot::Result<ProjectConfig> {
|
||||
Ok(ProjectConfig::builder()
|
||||
.debug(true)
|
||||
.database(
|
||||
DatabaseConfig::builder()
|
||||
.url("sqlite://db.sqlite3?mode=rwc")
|
||||
.build(),
|
||||
)
|
||||
.debug(debug_enabled(config_name))
|
||||
.database(DatabaseConfig::builder().url(database_url()).build())
|
||||
.middlewares(
|
||||
MiddlewareConfig::builder()
|
||||
.session(
|
||||
SessionMiddlewareConfig::builder()
|
||||
.secure(false)
|
||||
.same_site(SameSite::Lax)
|
||||
.store(
|
||||
SessionStoreConfig::builder()
|
||||
.store_type(SessionStoreTypeConfig::Database)
|
||||
@@ -98,12 +131,32 @@ impl Project for PettingProject {
|
||||
}
|
||||
}
|
||||
|
||||
#[cot::main]
|
||||
fn main() -> impl Project {
|
||||
fn main() {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.try_init();
|
||||
PettingProject
|
||||
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build Tokio runtime");
|
||||
if let Err(error) = runtime.block_on(cot::run_cli(PettingProject)) {
|
||||
let message = error.to_string();
|
||||
let details = format!("{error:?}");
|
||||
eprintln!("Failed to start web-petting: {message}\nDetails: {details}");
|
||||
if details.contains("28P01") || details.contains("password authentication failed") {
|
||||
eprintln!(
|
||||
"\nPostgreSQL rejected the configured username or password.\n\
|
||||
Set the connection string before starting the application, for example:\n\n \
|
||||
WEB_PETTING_DATABASE_URL='postgresql://postgres:YOUR_PASSWORD@localhost:5432/web_petting' cargo run\n\n\
|
||||
WEB_PETTING_DATABASE_URL takes priority over DATABASE_URL.\n\
|
||||
Check the current value with: printenv WEB_PETTING_DATABASE_URL"
|
||||
);
|
||||
} else if message.to_ascii_lowercase().contains("database") {
|
||||
eprintln!(
|
||||
"\nConfigure PostgreSQL with WEB_PETTING_DATABASE_URL or DATABASE_URL.\n\
|
||||
Example:\n\n WEB_PETTING_DATABASE_URL='postgresql://postgres:YOUR_PASSWORD@localhost:5432/web_petting' cargo run"
|
||||
);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-11
@@ -1,19 +1,11 @@
|
||||
//! List of migrations for the current app.
|
||||
//!
|
||||
//! Generated by cot CLI 0.6.0 on 2026-04-29 10:36:47+00:00
|
||||
//! Squashed for the PostgreSQL migration on 2026-07-11.
|
||||
|
||||
pub mod m_0001_initial;
|
||||
pub mod m_0002_visit_schedule;
|
||||
pub mod m_0003_visit_feedback;
|
||||
pub mod m_0004_visit_public_notes;
|
||||
pub mod m_0005_testimonials;
|
||||
pub mod m_0006_user_telegram;
|
||||
pub mod m_0002_push_subscription;
|
||||
/// The list of migrations for current app.
|
||||
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
||||
&m_0001_initial::Migration,
|
||||
&m_0002_visit_schedule::Migration,
|
||||
&m_0003_visit_feedback::Migration,
|
||||
&m_0004_visit_public_notes::Migration,
|
||||
&m_0005_testimonials::Migration,
|
||||
&m_0006_user_telegram::Migration,
|
||||
&m_0002_push_subscription::Migration,
|
||||
];
|
||||
|
||||
+376
-459
@@ -1,7 +1,8 @@
|
||||
//! Generated by cot CLI 0.6.0 on 2026-04-29 10:36:47+00:00
|
||||
//! Initial PostgreSQL schema for the current data model.
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0001_initial";
|
||||
@@ -9,479 +10,395 @@ impl ::cot::db::migrations::Migration for Migration {
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("login"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("password_hash"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("display_name"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("login"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("password_hash"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("display_name"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_chat_id"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_notifications"),
|
||||
<Option<bool> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<bool> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__setting"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("key"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("value"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("key"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("value"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__client"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("address"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("media_token"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("address"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("media_token"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("color"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__testimonial"))
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("text"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("author_note"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("image_path"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("sort_order"),
|
||||
<i32 as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<i32 as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("scheduled_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("duration_minutes"),
|
||||
<Option<i32> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<i32> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("user_id"),
|
||||
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::User as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::User as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_date"),
|
||||
<chrono::NaiveDate as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDate as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_start"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_end"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("public_notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_feedback"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__media"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_id"),
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Visit>,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Visit as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Visit as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Visit>,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_path"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_type"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("caption"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_id"),
|
||||
<Option<cot::db::ForeignKey<crate::models::Visit>> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Visit as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Visit as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<cot::db::ForeignKey<crate::models::Visit>> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_path"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_type"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("caption"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__lead"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("comment"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Client>,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Client>,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("comment"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Client {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub name: String,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
/// Unique token for the public media page (client views photos/videos here).
|
||||
#[model(unique)]
|
||||
pub media_token: String,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Lead {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub name: String,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
/// new | in_progress | converted | rejected
|
||||
pub status: String,
|
||||
pub client_id: Option<cot::db::ForeignKey<crate::models::Client>>,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Media {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub client_id: cot::db::ForeignKey<crate::models::Client>,
|
||||
pub visit_id: Option<cot::db::ForeignKey<crate::models::Visit>>,
|
||||
pub file_path: String,
|
||||
/// photo | video
|
||||
pub file_type: String,
|
||||
pub caption: Option<String>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Setting {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
#[model(unique)]
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _User {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
#[model(unique)]
|
||||
pub login: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Visit {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub client_id: cot::db::ForeignKey<crate::models::Client>,
|
||||
pub scheduled_at: chrono::NaiveDateTime,
|
||||
pub duration_minutes: Option<i32>,
|
||||
pub notes: Option<String>,
|
||||
/// scheduled | completed | cancelled
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Store browser Web Push subscriptions for client devices.
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0002_push_subscription";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0001_initial",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__push_subscription"))
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("endpoint"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(false).unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("p256dh"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(false),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("auth"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(false),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("language"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(false),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(false),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(false),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(false),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Migration: update Visit model for scheduling + add Client.color
|
||||
//! Visit: Remove scheduled_at, duration_minutes; Add user_id, visit_date, time_start, time_end
|
||||
//! Client: Add color
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0002_visit_schedule";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0001_initial",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
// Add color to client (nullable for existing rows)
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__client"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("color"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
// Remove old visit fields
|
||||
::cot::db::migrations::Operation::remove_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("scheduled_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
))
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::remove_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("duration_minutes"),
|
||||
<Option<i32> as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(<Option<i32> as ::cot::db::DatabaseField>::NULLABLE))
|
||||
.build(),
|
||||
// Add new fields
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("user_id"),
|
||||
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::User as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::User as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_date"),
|
||||
<chrono::NaiveDate as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDate as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_start"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_end"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//! Migration: add client_feedback to Visit
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0003_visit_feedback";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0002_visit_schedule",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] =
|
||||
&[::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_feedback"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build()];
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//! Migration: add public_notes to Visit
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0004_visit_public_notes";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0003_visit_feedback",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] =
|
||||
&[::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("public_notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build()];
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//! Migration: create Testimonial table
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0005_testimonials";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0004_visit_public_notes",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] =
|
||||
&[::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__testimonial"))
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("text"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("author_note"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("image_path"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("sort_order"),
|
||||
<i32 as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<i32 as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build()];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! Migration: add telegram_chat_id and telegram_notifications to User
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0006_user_telegram";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0005_testimonials",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_chat_id"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_notifications"),
|
||||
<Option<bool> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<bool> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
+21
-1
@@ -43,6 +43,7 @@ pub enum VisitStatus {
|
||||
Scheduled,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl VisitStatus {
|
||||
@@ -51,6 +52,7 @@ impl VisitStatus {
|
||||
Self::Scheduled => "scheduled",
|
||||
Self::Completed => "completed",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +148,7 @@ pub struct Visit {
|
||||
pub public_notes: Option<String>,
|
||||
/// Feedback text from client via portal.
|
||||
pub client_feedback: Option<String>,
|
||||
/// scheduled | completed | cancelled
|
||||
/// scheduled | completed | cancelled | deleted
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
@@ -215,3 +217,21 @@ pub struct Setting {
|
||||
pub value: String,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
/// A browser Web Push subscription belonging to a client device.
|
||||
#[derive(Debug, Clone)]
|
||||
#[model]
|
||||
pub struct PushSubscription {
|
||||
#[model(primary_key)]
|
||||
pub id: Auto<i64>,
|
||||
pub client_id: ForeignKey<Client>,
|
||||
#[model(unique)]
|
||||
pub endpoint: String,
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
pub language: String,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
+661
-61
@@ -1,17 +1,20 @@
|
||||
use chrono::Datelike;
|
||||
use cot::Template;
|
||||
use cot::db::{Auto, Database, Model};
|
||||
use cot::db::{Auto, Database, ForeignKey, Model};
|
||||
use cot::html::Html;
|
||||
use cot::request::Request;
|
||||
use cot::request::extractors::Path;
|
||||
use cot::response::{IntoResponse, Redirect, Response};
|
||||
use cot::router::{Route, Router};
|
||||
use serde::Deserialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Deref;
|
||||
use tracing::info;
|
||||
|
||||
use cot::db::query;
|
||||
|
||||
use crate::i18n::{Lang, Translations};
|
||||
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
|
||||
use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit};
|
||||
use crate::telegram;
|
||||
|
||||
fn detect_lang(request: &Request) -> Lang {
|
||||
@@ -73,12 +76,26 @@ struct LandingTemplate<'a> {
|
||||
contact_info: String,
|
||||
pricing_info: String,
|
||||
seo_keywords: String,
|
||||
testimonials: Vec<Testimonial>,
|
||||
testimonials: Vec<TestimonialView>,
|
||||
site_domain: String,
|
||||
review_count: usize,
|
||||
turnstile_site_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestimonialView {
|
||||
testimonial: Testimonial,
|
||||
image_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Deref for TestimonialView {
|
||||
type Target = Testimonial;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.testimonial
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "thank_you.html")]
|
||||
struct ThankYouTemplate<'a> {
|
||||
@@ -144,13 +161,33 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||
testimonials.retain(|t| t.status == "active");
|
||||
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
||||
let review_count = testimonials.len();
|
||||
let storage = crate::uploads::Storage::load(&db).await?;
|
||||
let mut testimonial_views = Vec::with_capacity(testimonials.len());
|
||||
for testimonial in testimonials {
|
||||
let image_url = match testimonial.image_path.as_deref() {
|
||||
Some(path) => Some(
|
||||
storage
|
||||
.public_url(
|
||||
path,
|
||||
format!("/testimonial-image/{}", testimonial.id.unwrap()),
|
||||
)
|
||||
.await
|
||||
.map_err(crate::uploads::storage_error)?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
testimonial_views.push(TestimonialView {
|
||||
testimonial,
|
||||
image_url,
|
||||
});
|
||||
}
|
||||
let body = LandingTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
contact_info,
|
||||
pricing_info,
|
||||
seo_keywords,
|
||||
testimonials,
|
||||
testimonials: testimonial_views,
|
||||
site_domain,
|
||||
review_count,
|
||||
turnstile_site_key,
|
||||
@@ -212,7 +249,72 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
|
||||
struct PortalVisit {
|
||||
visit: Visit,
|
||||
admin_name: String,
|
||||
media: Vec<Media>,
|
||||
media: Vec<PortalMediaView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PortalMediaView {
|
||||
media: Media,
|
||||
url: String,
|
||||
thumbnail_url: String,
|
||||
}
|
||||
|
||||
impl Deref for PortalMediaView {
|
||||
type Target = Media;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.media
|
||||
}
|
||||
}
|
||||
|
||||
async fn portal_media_view(
|
||||
storage: &crate::uploads::Storage,
|
||||
media: Media,
|
||||
client_token: &str,
|
||||
) -> cot::Result<PortalMediaView> {
|
||||
let media_id = media.id.unwrap();
|
||||
let delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
|
||||
let url = storage
|
||||
.public_url(
|
||||
&delivery.media_path,
|
||||
format!("/client/{client_token}/media/{media_id}"),
|
||||
)
|
||||
.await
|
||||
.map_err(crate::uploads::storage_error)?;
|
||||
let thumbnail_fallback = format!("/client/{client_token}/media/{media_id}/thumbnail");
|
||||
let thumbnail_url = if storage.is_r2()
|
||||
&& storage
|
||||
.exists(&delivery.thumbnail_path)
|
||||
.await
|
||||
.map_err(crate::uploads::storage_error)?
|
||||
{
|
||||
storage
|
||||
.public_url(&delivery.thumbnail_path, thumbnail_fallback)
|
||||
.await
|
||||
.map_err(crate::uploads::storage_error)?
|
||||
} else {
|
||||
thumbnail_fallback
|
||||
};
|
||||
Ok(PortalMediaView {
|
||||
media,
|
||||
url,
|
||||
thumbnail_url,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CalendarDay {
|
||||
number: u32,
|
||||
class_name: &'static str,
|
||||
href: Option<String>,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CalendarMonth {
|
||||
label: String,
|
||||
leading_blanks: Vec<u8>,
|
||||
days: Vec<CalendarDay>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
@@ -225,6 +327,62 @@ struct ClientPortalTemplate<'a> {
|
||||
past: Vec<PortalVisit>,
|
||||
feedback_sent: bool,
|
||||
turnstile_site_key: String,
|
||||
notifications_enabled: bool,
|
||||
vapid_public_key: String,
|
||||
calendar_months: Vec<CalendarMonth>,
|
||||
page: usize,
|
||||
total_pages: usize,
|
||||
has_previous_page: bool,
|
||||
has_next_page: bool,
|
||||
}
|
||||
|
||||
const PORTAL_VISITS_PER_PAGE: usize = 10;
|
||||
|
||||
fn query_page(request: &Request) -> usize {
|
||||
request
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|query| {
|
||||
query.split('&').find_map(|part| {
|
||||
part.strip_prefix("page=")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|page| *page > 0)
|
||||
})
|
||||
})
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
fn month_label(year: i32, month: u32, lang: Lang) -> String {
|
||||
const RU: [&str; 12] = [
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
];
|
||||
const EN: [&str; 12] = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
let names = if lang == Lang::Ru { RU } else { EN };
|
||||
format!("{} {year}", names[(month - 1) as usize])
|
||||
}
|
||||
|
||||
async fn client_portal(
|
||||
@@ -238,9 +396,11 @@ async fn client_portal(
|
||||
.query()
|
||||
.map(|q| q.split('&').any(|p| p == "feedback=ok"))
|
||||
.unwrap_or(false);
|
||||
let requested_page = query_page(&request);
|
||||
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) => c,
|
||||
Some(c) if c.status != "deleted" => c,
|
||||
Some(_) => return Html::new("404").into_response(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
|
||||
@@ -249,15 +409,59 @@ async fn client_portal(
|
||||
let today = crate::tz::today_in_tz(tz);
|
||||
|
||||
let mut visits = Visit::objects().all(&db).await?;
|
||||
visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled");
|
||||
visits.retain(|v| {
|
||||
v.client_id.primary_key().unwrap() == client_id
|
||||
&& v.status != "cancelled"
|
||||
&& v.status != "deleted"
|
||||
});
|
||||
visits.sort_by(|a, b| {
|
||||
a.visit_date
|
||||
.cmp(&b.visit_date)
|
||||
.then(a.time_start.cmp(&b.time_start))
|
||||
});
|
||||
|
||||
let mut upcoming_visits = Vec::new();
|
||||
let mut past_visits = Vec::new();
|
||||
for v in visits {
|
||||
if v.visit_date >= today && v.status == "scheduled" {
|
||||
upcoming_visits.push(v);
|
||||
} else {
|
||||
past_visits.push(v);
|
||||
}
|
||||
}
|
||||
past_visits.reverse(); // newest first
|
||||
|
||||
let total_pages = past_visits.len().div_ceil(PORTAL_VISITS_PER_PAGE).max(1);
|
||||
let page = requested_page.min(total_pages);
|
||||
let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE;
|
||||
let page_end = (page_start + PORTAL_VISITS_PER_PAGE).min(past_visits.len());
|
||||
let visible_visit_ids: HashSet<i64> = past_visits[page_start..page_end]
|
||||
.iter()
|
||||
.chain(upcoming_visits.iter())
|
||||
.map(|visit| visit.id.unwrap())
|
||||
.collect();
|
||||
|
||||
let users = User::objects().all(&db).await?;
|
||||
let all_media = Media::objects().all(&db).await?;
|
||||
let storage = crate::uploads::Storage::load(&db).await?;
|
||||
let mut media_by_visit: HashMap<i64, Vec<PortalMediaView>> = HashMap::new();
|
||||
for media in all_media {
|
||||
if media.status != "active" || media.client_id.primary_key().unwrap() != client_id {
|
||||
continue;
|
||||
}
|
||||
let Some(visit_id) = media
|
||||
.visit_id
|
||||
.as_ref()
|
||||
.map(|foreign_key| foreign_key.primary_key().unwrap())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !visible_visit_ids.contains(&visit_id) {
|
||||
continue;
|
||||
}
|
||||
let media_view = portal_media_view(&storage, media, &client.media_token).await?;
|
||||
media_by_visit.entry(visit_id).or_default().push(media_view);
|
||||
}
|
||||
|
||||
let build_portal_visit = |v: Visit| -> PortalVisit {
|
||||
let uid: i64 = v.user_id.primary_key().unwrap();
|
||||
@@ -267,17 +471,7 @@ async fn client_portal(
|
||||
.map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string())
|
||||
.unwrap_or_default();
|
||||
let vid = v.id.unwrap();
|
||||
let media: Vec<Media> = all_media
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.status == "active"
|
||||
&& m.visit_id
|
||||
.as_ref()
|
||||
.map(|fk| fk.primary_key().unwrap() == vid)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let media = media_by_visit.get(&vid).cloned().unwrap_or_default();
|
||||
PortalVisit {
|
||||
visit: v,
|
||||
admin_name,
|
||||
@@ -285,16 +479,95 @@ async fn client_portal(
|
||||
}
|
||||
};
|
||||
|
||||
let mut upcoming = Vec::new();
|
||||
let mut past = Vec::new();
|
||||
for v in visits {
|
||||
if v.visit_date >= today && v.status == "scheduled" {
|
||||
upcoming.push(build_portal_visit(v));
|
||||
} else {
|
||||
past.push(build_portal_visit(v));
|
||||
}
|
||||
}
|
||||
past.reverse(); // newest first
|
||||
let past = past_visits[page_start..page_end]
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(&build_portal_visit)
|
||||
.collect();
|
||||
let upcoming: Vec<_> = upcoming_visits
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(&build_portal_visit)
|
||||
.collect();
|
||||
|
||||
let mut month_keys: Vec<(i32, u32)> = past_visits
|
||||
.iter()
|
||||
.chain(upcoming_visits.iter())
|
||||
.map(|visit| (visit.visit_date.year(), visit.visit_date.month()))
|
||||
.collect();
|
||||
month_keys.sort();
|
||||
month_keys.dedup();
|
||||
month_keys.reverse();
|
||||
let calendar_months = month_keys
|
||||
.into_iter()
|
||||
.map(|(year, month)| {
|
||||
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
||||
let next_month = if month == 12 {
|
||||
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
|
||||
} else {
|
||||
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
|
||||
};
|
||||
let days_in_month = (next_month - first).num_days() as u32;
|
||||
let leading_blanks = vec![0; first.weekday().num_days_from_monday() as usize];
|
||||
let days = (1..=days_in_month)
|
||||
.map(|day| {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap();
|
||||
if let Some(_visit) = upcoming_visits
|
||||
.iter()
|
||||
.find(|visit| visit.visit_date == date)
|
||||
{
|
||||
CalendarDay {
|
||||
number: day,
|
||||
class_name: "future",
|
||||
href: None,
|
||||
title: lang.t().portal_future_visit.to_string(),
|
||||
}
|
||||
} else if let Some((index, visit)) = past_visits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, visit)| visit.visit_date == date)
|
||||
{
|
||||
CalendarDay {
|
||||
number: day,
|
||||
class_name: "past",
|
||||
href: Some(format!(
|
||||
"?page={}#visit-{}",
|
||||
index / PORTAL_VISITS_PER_PAGE + 1,
|
||||
visit.id.unwrap()
|
||||
)),
|
||||
title: lang.t().visit_status(&visit.status).to_string(),
|
||||
}
|
||||
} else {
|
||||
CalendarDay {
|
||||
number: day,
|
||||
class_name: "empty",
|
||||
href: None,
|
||||
title: String::new(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
CalendarMonth {
|
||||
label: month_label(year, month, lang),
|
||||
leading_blanks,
|
||||
days,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let notification_key = "client_notifications_enabled".to_string();
|
||||
let vapid_public_key = crate::web_push::load_config(&db)
|
||||
.await
|
||||
.map(|config| config.public_key)
|
||||
.unwrap_or_default();
|
||||
// The administrator setting controls whether the client can see notification
|
||||
// controls. Keep this independent from VAPID validation so a configuration
|
||||
// error is visible in the modal instead of silently removing the button.
|
||||
let notifications_enabled = query!(Setting, $key == notification_key)
|
||||
.get(&db)
|
||||
.await?
|
||||
.map(|setting| setting.value == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||
let body = ClientPortalTemplate {
|
||||
@@ -305,11 +578,203 @@ async fn client_portal(
|
||||
past,
|
||||
feedback_sent,
|
||||
turnstile_site_key,
|
||||
notifications_enabled,
|
||||
vapid_public_key,
|
||||
calendar_months,
|
||||
page,
|
||||
total_pages,
|
||||
has_previous_page: page > 1,
|
||||
has_next_page: page < total_pages,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushKeysForm {
|
||||
p256dh: String,
|
||||
auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushSubscriptionForm {
|
||||
endpoint: String,
|
||||
keys: PushKeysForm,
|
||||
#[serde(default)]
|
||||
language: String,
|
||||
}
|
||||
|
||||
async fn portal_push_subscribe(
|
||||
request: Request,
|
||||
db: Database,
|
||||
Path(token): Path<String>,
|
||||
) -> cot::Result<Response> {
|
||||
tracing::info!("client Web Push subscription request");
|
||||
if crate::web_push::load_config(&db).await.is_none() {
|
||||
return Html::new("404").into_response();
|
||||
}
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(client) if client.status != "deleted" => client,
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
let bytes = request.into_body().into_bytes().await?;
|
||||
let form: PushSubscriptionForm =
|
||||
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||
if !form.endpoint.starts_with("https://")
|
||||
|| form.endpoint.len() > 4096
|
||||
|| form.keys.p256dh.len() > 512
|
||||
|| form.keys.auth.len() > 256
|
||||
{
|
||||
let mut response = Response::new(cot::Body::fixed(
|
||||
"{\"ok\":false,\"error\":\"invalid subscription\"}",
|
||||
));
|
||||
*response.status_mut() = cot::StatusCode::BAD_REQUEST;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/json".parse().unwrap());
|
||||
return Ok(response);
|
||||
}
|
||||
tracing::info!(
|
||||
client_id = client.id.unwrap(),
|
||||
"client Web Push subscription saved"
|
||||
);
|
||||
let endpoint = form.endpoint.clone();
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
||||
.get(&db)
|
||||
.await?
|
||||
{
|
||||
subscription.client_id = ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap()));
|
||||
subscription.p256dh = form.keys.p256dh;
|
||||
subscription.auth = form.keys.auth;
|
||||
subscription.language = if form.language == "ru" { "ru" } else { "en" }.to_string();
|
||||
subscription.status = "active".to_string();
|
||||
subscription.updated_at = now;
|
||||
subscription.save(&db).await?;
|
||||
} else {
|
||||
let mut subscription = PushSubscription {
|
||||
id: Auto::auto(),
|
||||
client_id: ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap())),
|
||||
endpoint: form.endpoint,
|
||||
p256dh: form.keys.p256dh,
|
||||
auth: form.keys.auth,
|
||||
language: if form.language == "ru" { "ru" } else { "en" }.to_string(),
|
||||
status: "active".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
subscription.save(&db).await?;
|
||||
}
|
||||
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/json".parse().unwrap());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn portal_push_unsubscribe(
|
||||
request: Request,
|
||||
db: Database,
|
||||
Path(token): Path<String>,
|
||||
) -> cot::Result<Response> {
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(client) if client.status != "deleted" => client,
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
let bytes = request.into_body().into_bytes().await?;
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||
let endpoint = value
|
||||
.get("endpoint")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
||||
.get(&db)
|
||||
.await?
|
||||
{
|
||||
if subscription.client_id.primary_key().unwrap() == client.id.unwrap() {
|
||||
subscription.status = "archived".to_string();
|
||||
subscription.updated_at = chrono::Utc::now().naive_utc();
|
||||
subscription.save(&db).await?;
|
||||
}
|
||||
}
|
||||
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/json".parse().unwrap());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn web_manifest(_request: Request, Path(token): Path<String>) -> cot::Result<Response> {
|
||||
let manifest = serde_json::json!({
|
||||
"id": format!("/client/{token}"),
|
||||
"name": "Pet Sitting Visits",
|
||||
"short_name": "Pet Visits",
|
||||
"start_url": format!("/client/{token}"),
|
||||
"display": "standalone",
|
||||
"background_color": "#f8f7ff",
|
||||
"theme_color": "#7c6cff",
|
||||
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
|
||||
});
|
||||
let mut response = Response::new(cot::Body::fixed(manifest.to_string()));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/manifest+json".parse().unwrap());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn service_worker(_request: Request) -> cot::Result<Response> {
|
||||
let script = r#"
|
||||
self.addEventListener('install', function(event) {
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener('activate', function(event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
self.addEventListener('push', function(event) {
|
||||
var data = event.data ? event.data.json() : {};
|
||||
event.waitUntil(self.registration.showNotification(data.title || 'Pet Visits', {
|
||||
body: data.body || '', tag: data.tag || 'visit', data: { url: data.url || '/' },
|
||||
icon: '/favicon.svg', badge: '/favicon.svg'
|
||||
}));
|
||||
});
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
event.notification.close();
|
||||
var target = new URL(event.notification.data.url || '/', self.location.origin).href;
|
||||
event.waitUntil((async function() {
|
||||
var list = await clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i].url === target && 'focus' in list[i]) {
|
||||
return list[i].focus();
|
||||
}
|
||||
}
|
||||
for (var j = 0; j < list.length; j++) {
|
||||
if ('navigate' in list[j] && 'focus' in list[j]) {
|
||||
try {
|
||||
var navigated = await list[j].navigate(target);
|
||||
return navigated ? navigated.focus() : list[j].focus();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow(target);
|
||||
})());
|
||||
});
|
||||
"#;
|
||||
let mut response = Response::new(cot::Body::fixed(script));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", "application/javascript".parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("service-worker-allowed", "/".parse().unwrap());
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
"no-cache, no-store, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FeedbackForm {
|
||||
feedback: String,
|
||||
@@ -327,7 +792,8 @@ async fn submit_feedback(
|
||||
// Verify token matches visit's client
|
||||
let token_clone = token.clone();
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) => c,
|
||||
Some(c) if c.status != "deleted" => c,
|
||||
Some(_) => return Html::new("404").into_response(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
let client_id = client.id.unwrap();
|
||||
@@ -337,15 +803,15 @@ async fn submit_feedback(
|
||||
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? {
|
||||
return Redirect::new(format!(
|
||||
"/client/{}?lang={}",
|
||||
token_clone,
|
||||
lang.code()
|
||||
))
|
||||
.into_response();
|
||||
return Redirect::new(format!("/client/{}?lang={}", token_clone, lang.code()))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||
if visit.status == "deleted" {
|
||||
return Redirect::new(format!("/client/{}?lang={}", token_clone, lang.code()))
|
||||
.into_response();
|
||||
}
|
||||
if visit.client_id.primary_key().unwrap() == client_id {
|
||||
visit.client_feedback = Some(form.feedback);
|
||||
visit.updated_at = now_utc();
|
||||
@@ -363,13 +829,14 @@ async fn submit_feedback(
|
||||
|
||||
/// Serve media files for the client portal (no auth required, but only via token).
|
||||
async fn portal_media(
|
||||
_request: Request,
|
||||
request: Request,
|
||||
db: Database,
|
||||
Path((token, media_id)): Path<(String, i64)>,
|
||||
) -> cot::Result<Response> {
|
||||
// Verify token
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) => c,
|
||||
Some(c) if c.status != "deleted" => c,
|
||||
Some(_) => return Html::new("404").into_response(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
let client_id = client.id.unwrap();
|
||||
@@ -378,28 +845,120 @@ async fn portal_media(
|
||||
Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m,
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
if let Some(fk) = &media.visit_id {
|
||||
let visit_id: i64 = fk.primary_key().unwrap();
|
||||
match query!(Visit, $id == visit_id).get(&db).await? {
|
||||
Some(v) if v.status != "deleted" => {}
|
||||
_ => return Html::new("404").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
match tokio::fs::read(&media.file_path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"heic" | "heif" => "image/heic",
|
||||
"webp" => "image/webp",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
let body = cot::Body::fixed(data);
|
||||
let mut resp = Response::new(body);
|
||||
resp.headers_mut()
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
Ok(resp)
|
||||
let range = request
|
||||
.headers()
|
||||
.get("range")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
let storage = crate::uploads::Storage::load(&db).await?;
|
||||
let display_path =
|
||||
crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path;
|
||||
if storage.is_r2() {
|
||||
let url = storage
|
||||
.public_url(&display_path, String::new())
|
||||
.await
|
||||
.map_err(crate::uploads::storage_error)?;
|
||||
return Redirect::new(url).into_response();
|
||||
}
|
||||
|
||||
match crate::uploads::ranged_local_file_response(
|
||||
&display_path,
|
||||
crate::uploads::content_type_for_path(&display_path),
|
||||
range.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => Ok(response),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
media_id,
|
||||
db_path = %display_path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&display_path),
|
||||
error = %err,
|
||||
"portal media file is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn portal_media_thumbnail(
|
||||
_request: Request,
|
||||
db: Database,
|
||||
Path((token, media_id)): Path<(String, i64)>,
|
||||
) -> cot::Result<Response> {
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(client) if client.status != "deleted" => client,
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
let media = match query!(Media, $id == media_id).get(&db).await? {
|
||||
Some(media)
|
||||
if media.client_id.primary_key().unwrap() == client.id.unwrap()
|
||||
&& media.status == "active" =>
|
||||
{
|
||||
media
|
||||
}
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
if let Some(visit) = &media.visit_id {
|
||||
let visit_id = visit.primary_key().unwrap();
|
||||
match query!(Visit, $id == visit_id).get(&db).await? {
|
||||
Some(visit) if visit.status != "deleted" => {}
|
||||
_ => return Html::new("404").into_response(),
|
||||
}
|
||||
}
|
||||
let storage = crate::uploads::Storage::load(&db).await?;
|
||||
let display_path = match crate::uploads::ensure_media_delivery_paths(
|
||||
&storage,
|
||||
&media.file_type,
|
||||
&media.file_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(paths) => paths.thumbnail_path,
|
||||
Err(error) => {
|
||||
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
|
||||
if media.file_type == "photo" {
|
||||
media.file_path.clone()
|
||||
} else {
|
||||
return Html::new("404").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
if storage.is_r2() {
|
||||
let url = storage
|
||||
.public_url(&display_path, String::new())
|
||||
.await
|
||||
.map_err(crate::uploads::storage_error)?;
|
||||
return Redirect::new(url).into_response();
|
||||
}
|
||||
let content_type = if display_path == media.file_path {
|
||||
crate::uploads::content_type_for_path(&media.file_path)
|
||||
} else {
|
||||
"image/jpeg"
|
||||
};
|
||||
match crate::uploads::ranged_local_file_response(&display_path, content_type, None).await {
|
||||
Ok(mut response) => {
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
"private, max-age=31536000, immutable".parse().unwrap(),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(media_id, %error, "failed to read portal media thumbnail");
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +975,15 @@ async fn serve_testimonial_image(
|
||||
Some(p) => p.clone(),
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
match tokio::fs::read(&path).await {
|
||||
let storage = crate::uploads::Storage::load(&db).await?;
|
||||
if storage.is_r2() {
|
||||
let url = storage
|
||||
.public_url(&path, String::new())
|
||||
.await
|
||||
.map_err(crate::uploads::storage_error)?;
|
||||
return Redirect::new(url).into_response();
|
||||
}
|
||||
match storage.read(&path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
@@ -433,7 +1000,17 @@ async fn serve_testimonial_image(
|
||||
.insert("cache-control", "public, max-age=86400".parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "uploads",
|
||||
testimonial_id = id,
|
||||
db_path = %path,
|
||||
resolved_path = %crate::uploads::resolved_display_path(&path),
|
||||
error = %err,
|
||||
"testimonial image is missing or unreadable"
|
||||
);
|
||||
Html::new("404").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,8 +1107,10 @@ async fn sitemap_xml(_request: Request, db: Database) -> cot::Result<Response> {
|
||||
domain = site_domain
|
||||
);
|
||||
let mut resp = Response::new(cot::Body::fixed(body.into_bytes()));
|
||||
resp.headers_mut()
|
||||
.insert("content-type", "application/xml; charset=utf-8".parse().unwrap());
|
||||
resp.headers_mut().insert(
|
||||
"content-type",
|
||||
"application/xml; charset=utf-8".parse().unwrap(),
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
@@ -539,6 +1118,12 @@ pub fn public_router() -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_handler_and_name("/", landing_page, "landing"),
|
||||
Route::with_handler_and_name("/favicon.svg", favicon, "favicon"),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/manifest.webmanifest",
|
||||
web_manifest,
|
||||
"web-manifest",
|
||||
),
|
||||
Route::with_handler_and_name("/service-worker.js", service_worker, "service-worker"),
|
||||
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
|
||||
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
|
||||
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
|
||||
@@ -549,6 +1134,16 @@ pub fn public_router() -> Router {
|
||||
"testimonial-image",
|
||||
),
|
||||
Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/push/subscribe",
|
||||
portal_push_subscribe,
|
||||
"client-push-subscribe",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/push/unsubscribe",
|
||||
portal_push_unsubscribe,
|
||||
"client-push-unsubscribe",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/{visit_id}/feedback",
|
||||
submit_feedback,
|
||||
@@ -559,5 +1154,10 @@ pub fn public_router() -> Router {
|
||||
portal_media,
|
||||
"client-media",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/media/{media_id}/thumbnail",
|
||||
portal_media_thumbnail,
|
||||
"client-media-thumbnail",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
+991
@@ -0,0 +1,991 @@
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
|
||||
use aws_sdk_s3::presigning::PresigningConfig;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use cot::db::{Database, Model};
|
||||
use cot::response::Response;
|
||||
use cot::{Body, StatusCode};
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::models::Setting;
|
||||
|
||||
const THUMBNAIL_MAX_DIMENSION: u32 = 360;
|
||||
const THUMBNAIL_JPEG_QUALITY: u8 = 70;
|
||||
pub const VIDEO_PREVIEW_FRAME_COUNT: usize = 4;
|
||||
const VIDEO_PREVIEW_FRAME_WIDTH: u32 = 320;
|
||||
const VIDEO_PREVIEW_FRAME_HEIGHT: u32 = 240;
|
||||
const PRESIGNED_URL_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
|
||||
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
||||
const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR";
|
||||
|
||||
pub const R2_ENABLED_KEY: &str = "r2_enabled";
|
||||
pub const R2_ACCOUNT_ID_KEY: &str = "r2_account_id";
|
||||
pub const R2_BUCKET_KEY: &str = "r2_bucket";
|
||||
pub const R2_ACCESS_KEY_ID_KEY: &str = "r2_access_key_id";
|
||||
pub const R2_SECRET_ACCESS_KEY_KEY: &str = "r2_secret_access_key";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StorageError(String);
|
||||
|
||||
impl StorageError {
|
||||
fn new(context: &str, error: impl fmt::Display) -> Self {
|
||||
Self(format!("{context}: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for StorageError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StorageError {}
|
||||
|
||||
pub type StorageResult<T> = Result<T, StorageError>;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct R2Config {
|
||||
pub account_id: String,
|
||||
pub bucket: String,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
}
|
||||
|
||||
impl R2Config {
|
||||
pub fn from_settings(settings: &[Setting]) -> Option<Self> {
|
||||
let value = |key: &str| {
|
||||
settings
|
||||
.iter()
|
||||
.find(|setting| setting.key == key)
|
||||
.map(|setting| setting.value.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let config = Self {
|
||||
account_id: value(R2_ACCOUNT_ID_KEY),
|
||||
bucket: value(R2_BUCKET_KEY),
|
||||
access_key_id: value(R2_ACCESS_KEY_ID_KEY),
|
||||
secret_access_key: value(R2_SECRET_ACCESS_KEY_KEY),
|
||||
};
|
||||
config.is_valid().then_some(config)
|
||||
}
|
||||
|
||||
pub fn fields_are_valid(
|
||||
account_id: &str,
|
||||
bucket: &str,
|
||||
access_key_id: &str,
|
||||
secret_access_key: &str,
|
||||
) -> bool {
|
||||
let account_id = account_id.trim();
|
||||
let bucket = bucket.trim();
|
||||
account_id.len() == 32
|
||||
&& account_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
&& (3..=63).contains(&bucket.len())
|
||||
&& !bucket.starts_with('-')
|
||||
&& !bucket.ends_with('-')
|
||||
&& bucket
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& !access_key_id.trim().is_empty()
|
||||
&& !secret_access_key.trim().is_empty()
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
Self::fields_are_valid(
|
||||
&self.account_id,
|
||||
&self.bucket,
|
||||
&self.access_key_id,
|
||||
&self.secret_access_key,
|
||||
)
|
||||
}
|
||||
|
||||
fn endpoint(&self) -> String {
|
||||
format!("https://{}.r2.cloudflarestorage.com", self.account_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct R2Storage {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
}
|
||||
|
||||
impl R2Storage {
|
||||
fn new(config: R2Config) -> Self {
|
||||
let endpoint = config.endpoint();
|
||||
let bucket = config.bucket.clone();
|
||||
let credentials = Credentials::new(
|
||||
config.access_key_id,
|
||||
config.secret_access_key,
|
||||
None,
|
||||
None,
|
||||
"web-petting-r2",
|
||||
);
|
||||
let sdk_config = aws_sdk_s3::Config::builder()
|
||||
.behavior_version(BehaviorVersion::latest())
|
||||
.endpoint_url(endpoint)
|
||||
.credentials_provider(credentials)
|
||||
.region(Region::new("auto"))
|
||||
.build();
|
||||
Self {
|
||||
client: Client::from_conf(sdk_config),
|
||||
bucket,
|
||||
}
|
||||
}
|
||||
|
||||
async fn put(&self, db_path: &str, data: &[u8]) -> StorageResult<()> {
|
||||
self.put_stream(db_path, ByteStream::from(data.to_vec()))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put_stream(&self, db_path: &str, body: ByteStream) -> StorageResult<()> {
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(object_key(db_path))
|
||||
.content_type(content_type_for_path(db_path))
|
||||
.cache_control("private, max-age=21600")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to upload object to R2", error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get(&self, db_path: &str) -> StorageResult<Vec<u8>> {
|
||||
let response = self
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(object_key(db_path))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to download object from R2", error))?;
|
||||
let bytes = response
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to read R2 response body", error))?;
|
||||
Ok(bytes.into_bytes().to_vec())
|
||||
}
|
||||
|
||||
async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
|
||||
let response = self
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(object_key(db_path))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to download object from R2", error))?;
|
||||
let mut reader = response.body.into_async_read();
|
||||
let mut file = tokio::fs::File::create(destination)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to create temporary media file", error))?;
|
||||
tokio::io::copy(&mut reader, &mut file)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to stream R2 object to disk", error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, db_path: &str) -> StorageResult<()> {
|
||||
self.client
|
||||
.delete_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(object_key(db_path))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to delete object from R2", error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exists(&self, db_path: &str) -> StorageResult<bool> {
|
||||
match self
|
||||
.client
|
||||
.head_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(object_key(db_path))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(error)
|
||||
if error
|
||||
.as_service_error()
|
||||
.is_some_and(|service_error| service_error.is_not_found()) =>
|
||||
{
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => Err(StorageError::new("failed to inspect R2 object", error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn presigned_get_url(&self, db_path: &str) -> StorageResult<String> {
|
||||
let config = PresigningConfig::expires_in(PRESIGNED_URL_TTL)
|
||||
.map_err(|error| StorageError::new("failed to configure R2 signed URL", error))?;
|
||||
let request = self
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(object_key(db_path))
|
||||
.presigned(config)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to sign R2 object URL", error))?;
|
||||
Ok(request.uri().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Storage {
|
||||
Local,
|
||||
R2(R2Storage),
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub async fn load(db: &Database) -> cot::Result<Self> {
|
||||
let settings = Setting::objects().all(db).await?;
|
||||
let enabled = settings
|
||||
.iter()
|
||||
.find(|setting| setting.key == R2_ENABLED_KEY)
|
||||
.map(|setting| setting.value == "true")
|
||||
.unwrap_or(false);
|
||||
if !enabled {
|
||||
return Ok(Self::Local);
|
||||
}
|
||||
match R2Config::from_settings(&settings) {
|
||||
Some(config) => Ok(Self::R2(R2Storage::new(config))),
|
||||
None => {
|
||||
tracing::error!(
|
||||
target: "uploads",
|
||||
"R2 is enabled but its configuration is incomplete; using local storage"
|
||||
);
|
||||
Ok(Self::Local)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configured R2 even when it is not enabled yet. This lets the
|
||||
/// one-time migration run before the site is switched away from local storage.
|
||||
#[allow(dead_code)]
|
||||
pub async fn load_configured_r2(db: &Database) -> cot::Result<Self> {
|
||||
let settings = Setting::objects().all(db).await?;
|
||||
let config = R2Config::from_settings(&settings).ok_or_else(|| {
|
||||
cot::Error::internal("R2 settings are missing or invalid".to_string())
|
||||
})?;
|
||||
Ok(Self::R2(R2Storage::new(config)))
|
||||
}
|
||||
|
||||
pub fn is_r2(&self) -> bool {
|
||||
matches!(self, Self::R2(_))
|
||||
}
|
||||
|
||||
pub async fn create_logical_dir(&self, db_dir: &str) -> StorageResult<()> {
|
||||
if matches!(self, Self::Local) {
|
||||
tokio::fs::create_dir_all(resolve_db_path(db_dir))
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to create upload directory", error))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn write(&self, db_path: &str, data: &[u8]) -> StorageResult<()> {
|
||||
match self {
|
||||
Self::Local => {
|
||||
let physical_path = resolve_db_path(db_path);
|
||||
if let Some(parent) = physical_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
StorageError::new("failed to create upload directory", error)
|
||||
})?;
|
||||
}
|
||||
tokio::fs::write(physical_path, data)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to write uploaded file", error))
|
||||
}
|
||||
Self::R2(storage) => storage.put(db_path, data).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read(&self, db_path: &str) -> StorageResult<Vec<u8>> {
|
||||
match self {
|
||||
Self::Local => tokio::fs::read(resolve_db_path(db_path))
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to read uploaded file", error)),
|
||||
Self::R2(storage) => storage.get(db_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, db_path: &str) -> StorageResult<()> {
|
||||
match self {
|
||||
Self::Local => match tokio::fs::remove_file(resolve_db_path(db_path)).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(StorageError::new("failed to remove uploaded file", error)),
|
||||
},
|
||||
Self::R2(storage) => storage.delete(db_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exists(&self, db_path: &str) -> StorageResult<bool> {
|
||||
match self {
|
||||
Self::Local => tokio::fs::try_exists(resolve_db_path(db_path))
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to inspect uploaded file", error)),
|
||||
Self::R2(storage) => storage.exists(db_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn public_url(&self, db_path: &str, local_fallback: String) -> StorageResult<String> {
|
||||
match self {
|
||||
Self::Local => Ok(local_fallback),
|
||||
Self::R2(storage) => storage.presigned_get_url(db_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
|
||||
match self {
|
||||
Self::Local => {
|
||||
tokio::fs::copy(resolve_db_path(db_path), destination)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
StorageError::new("failed to copy local media to temporary file", error)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
Self::R2(storage) => storage.download_to_path(db_path, destination).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream a local PVC file into R2 without loading large videos into memory.
|
||||
#[allow(dead_code)]
|
||||
pub async fn upload_local_copy(&self, db_path: &str, local_path: &Path) -> StorageResult<()> {
|
||||
let Self::R2(storage) = self else {
|
||||
return Err(StorageError::new(
|
||||
"failed to migrate local file",
|
||||
"R2 storage is not configured",
|
||||
));
|
||||
};
|
||||
let body = ByteStream::from_path(local_path)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to open local file for migration", error))?;
|
||||
storage.put_stream(db_path, body).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn media_dir(client_id: i64, visit_id: i64) -> String {
|
||||
format!("{DEFAULT_UPLOAD_DIR}/{client_id}/{visit_id}")
|
||||
}
|
||||
|
||||
pub fn testimonials_dir() -> String {
|
||||
format!("{DEFAULT_UPLOAD_DIR}/testimonials")
|
||||
}
|
||||
|
||||
pub fn join_db_path(dir: &str, filename: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), filename)
|
||||
}
|
||||
|
||||
pub fn resolve_db_path(db_path: &str) -> PathBuf {
|
||||
let path = PathBuf::from(db_path);
|
||||
if path.is_absolute() {
|
||||
return path;
|
||||
}
|
||||
|
||||
let Some(upload_root) = std::env::var_os(UPLOAD_DIR_ENV) else {
|
||||
return path;
|
||||
};
|
||||
|
||||
let upload_root = PathBuf::from(upload_root);
|
||||
let logical_path = Path::new(db_path);
|
||||
match logical_path.strip_prefix(DEFAULT_UPLOAD_DIR) {
|
||||
Ok(stripped) => upload_root.join(stripped),
|
||||
Err(_) => upload_root.join(logical_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved_display_path(db_path: &str) -> String {
|
||||
resolve_db_path(db_path).display().to_string()
|
||||
}
|
||||
|
||||
pub fn object_key(db_path: &str) -> String {
|
||||
db_path
|
||||
.replace('\\', "/")
|
||||
.trim_start_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn thumbnail_db_path(db_path: &str) -> String {
|
||||
match db_path.rsplit_once('.') {
|
||||
Some((stem, _)) => format!("{stem}.thumb.jpg"),
|
||||
None => format!("{db_path}.thumb.jpg"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalized_video_db_path(db_path: &str) -> String {
|
||||
match db_path.rsplit_once('.') {
|
||||
Some((stem, _)) if stem.ends_with(".web") => format!("{stem}.mp4"),
|
||||
Some((stem, _)) => format!("{stem}.web.mp4"),
|
||||
None => format!("{db_path}.web.mp4"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_thumbnail(db_path: &str) -> bool {
|
||||
matches!(
|
||||
db_path
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"jpg" | "jpeg" | "png" | "webp"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn supports_video_preview(db_path: &str) -> bool {
|
||||
matches!(
|
||||
db_path
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"mp4" | "mov" | "avi" | "mkv" | "webm"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn content_type_for_path(path: &str) -> &'static str {
|
||||
match path
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"heic" | "heif" => "image/heic",
|
||||
"webp" => "image/webp",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_thumbnail(data: &[u8]) -> StorageResult<Vec<u8>> {
|
||||
let image = image::load_from_memory(data)
|
||||
.map_err(|error| StorageError::new("failed to decode image for thumbnail", error))?;
|
||||
let thumbnail = image.thumbnail(THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION);
|
||||
let rgb = thumbnail.to_rgb8();
|
||||
let mut encoded = Vec::new();
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut encoded, THUMBNAIL_JPEG_QUALITY);
|
||||
encoder
|
||||
.encode_image(&rgb)
|
||||
.map_err(|error| StorageError::new("failed to encode image thumbnail", error))?;
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
pub async fn ensure_thumbnail(storage: &Storage, db_path: &str) -> StorageResult<String> {
|
||||
let thumbnail_path = thumbnail_db_path(db_path);
|
||||
if storage.exists(&thumbnail_path).await? {
|
||||
return Ok(thumbnail_path);
|
||||
}
|
||||
let data = storage.read(db_path).await?;
|
||||
write_thumbnail(storage, db_path, &data).await?;
|
||||
Ok(thumbnail_path)
|
||||
}
|
||||
|
||||
pub async fn write_thumbnail(
|
||||
storage: &Storage,
|
||||
db_path: &str,
|
||||
source_data: &[u8],
|
||||
) -> StorageResult<String> {
|
||||
let thumbnail_path = thumbnail_db_path(db_path);
|
||||
let encoded = encode_thumbnail(source_data)?;
|
||||
storage.write(&thumbnail_path, &encoded).await?;
|
||||
Ok(thumbnail_path)
|
||||
}
|
||||
|
||||
/// Create a missing thumbnail from the local PVC even when R2 is enabled.
|
||||
#[allow(dead_code)]
|
||||
pub async fn ensure_local_thumbnail(db_path: &str) -> StorageResult<String> {
|
||||
let thumbnail_path = thumbnail_db_path(db_path);
|
||||
if tokio::fs::try_exists(resolve_db_path(&thumbnail_path))
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to inspect local thumbnail", error))?
|
||||
{
|
||||
return Ok(thumbnail_path);
|
||||
}
|
||||
let data = tokio::fs::read(resolve_db_path(db_path))
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to read local image", error))?;
|
||||
let encoded = encode_thumbnail(&data)?;
|
||||
let physical_path = resolve_db_path(&thumbnail_path);
|
||||
if let Some(parent) = physical_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to create thumbnail directory", error))?;
|
||||
}
|
||||
tokio::fs::write(physical_path, encoded)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to write local thumbnail", error))?;
|
||||
Ok(thumbnail_path)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MediaDeliveryPaths {
|
||||
pub media_path: String,
|
||||
pub thumbnail_path: String,
|
||||
}
|
||||
|
||||
async fn create_video_workspace() -> StorageResult<PathBuf> {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"web-petting-video-preview-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
tokio::fs::create_dir(&path)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to create video workspace", error))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
async fn run_media_command(command: &mut Command, context: &str) -> StorageResult<()> {
|
||||
let output = command
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| StorageError::new(context, error))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(StorageError::new(
|
||||
context,
|
||||
stderr
|
||||
.trim()
|
||||
.lines()
|
||||
.last()
|
||||
.unwrap_or("unknown ffmpeg error"),
|
||||
))
|
||||
}
|
||||
|
||||
async fn transcode_video_for_browser(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
command
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(source)
|
||||
.args(["-map", "0:v:0", "-map", "0:a?"])
|
||||
.args([
|
||||
"-vf",
|
||||
"scale=w='min(1920,iw)':h='min(1920,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-threads",
|
||||
"2",
|
||||
])
|
||||
.arg(destination);
|
||||
run_media_command(&mut command, "failed to transcode video with ffmpeg").await
|
||||
}
|
||||
|
||||
async fn video_duration(source: &Path) -> StorageResult<f64> {
|
||||
let mut command = Command::new("ffprobe");
|
||||
command
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
])
|
||||
.arg(source);
|
||||
let output = command
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to inspect video with ffprobe", error))?;
|
||||
if !output.status.success() {
|
||||
return Err(StorageError::new(
|
||||
"failed to inspect video with ffprobe",
|
||||
String::from_utf8_lossy(&output.stderr).trim(),
|
||||
));
|
||||
}
|
||||
let duration = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(|error| StorageError::new("failed to parse video duration", error))?;
|
||||
if !duration.is_finite() || duration <= 0.0 {
|
||||
return Err(StorageError::new(
|
||||
"failed to inspect video duration",
|
||||
"duration is zero or invalid",
|
||||
));
|
||||
}
|
||||
Ok(duration)
|
||||
}
|
||||
|
||||
async fn create_video_sprite(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||
let duration = video_duration(source).await?;
|
||||
let frame_times = [0.08, 0.34, 0.60, 0.86].map(|position| duration * position);
|
||||
let mut command = Command::new("ffmpeg");
|
||||
command.args(["-hide_banner", "-loglevel", "error", "-y"]);
|
||||
for timestamp in frame_times {
|
||||
command
|
||||
.arg("-ss")
|
||||
.arg(format!("{timestamp:.3}"))
|
||||
.arg("-i")
|
||||
.arg(source);
|
||||
}
|
||||
let frame_filter = format!(
|
||||
"scale={VIDEO_PREVIEW_FRAME_WIDTH}:{VIDEO_PREVIEW_FRAME_HEIGHT}:force_original_aspect_ratio=decrease,pad={VIDEO_PREVIEW_FRAME_WIDTH}:{VIDEO_PREVIEW_FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1"
|
||||
);
|
||||
let filter = format!(
|
||||
"[0:v]{frame_filter}[v0];[1:v]{frame_filter}[v1];[2:v]{frame_filter}[v2];[3:v]{frame_filter}[v3];[v0][v1][v2][v3]hstack=inputs={VIDEO_PREVIEW_FRAME_COUNT}[out]"
|
||||
);
|
||||
command
|
||||
.arg("-filter_complex")
|
||||
.arg(filter)
|
||||
.args(["-map", "[out]", "-frames:v", "1", "-q:v", "4"])
|
||||
.arg(destination);
|
||||
run_media_command(&mut command, "failed to create video preview with ffmpeg").await
|
||||
}
|
||||
|
||||
/// Create the compact browser MP4 and its four-frame JPEG sprite on disk.
|
||||
/// The source path is only read and is never changed or removed.
|
||||
pub async fn create_compact_video_files(
|
||||
source: &Path,
|
||||
video_destination: &Path,
|
||||
thumbnail_destination: &Path,
|
||||
) -> StorageResult<()> {
|
||||
transcode_video_for_browser(source, video_destination).await?;
|
||||
create_video_sprite(video_destination, thumbnail_destination).await
|
||||
}
|
||||
|
||||
/// Convert an incoming upload and store only the compact browser MP4 and its
|
||||
/// JPEG sprite. The original upload bytes are never written to storage.
|
||||
pub async fn write_compact_video(
|
||||
storage: &Storage,
|
||||
db_path: &str,
|
||||
source_extension: &str,
|
||||
source_data: &[u8],
|
||||
) -> StorageResult<()> {
|
||||
let workspace = create_video_workspace().await?;
|
||||
let source = workspace.join(format!("source.{source_extension}"));
|
||||
let compact_video = workspace.join("video.mp4");
|
||||
let thumbnail = workspace.join("preview.jpg");
|
||||
let result = async {
|
||||
tokio::fs::write(&source, source_data)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to write temporary video", error))?;
|
||||
create_compact_video_files(&source, &compact_video, &thumbnail).await?;
|
||||
let video_data = tokio::fs::read(&compact_video)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to read transcoded video", error))?;
|
||||
let thumbnail_data = tokio::fs::read(&thumbnail)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to read video preview", error))?;
|
||||
let thumbnail_path = thumbnail_db_path(db_path);
|
||||
storage.write(db_path, &video_data).await?;
|
||||
storage.write(&thumbnail_path, &thumbnail_data).await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let _ = tokio::fs::remove_dir_all(&workspace).await;
|
||||
result
|
||||
}
|
||||
|
||||
/// Create and persist a missing video sprite. In R2 mode the compact source is
|
||||
/// downloaded only once; subsequent page loads reuse the generated JPEG.
|
||||
pub async fn ensure_video_thumbnail(storage: &Storage, db_path: &str) -> StorageResult<String> {
|
||||
let thumbnail_path = thumbnail_db_path(db_path);
|
||||
if storage.exists(&thumbnail_path).await? {
|
||||
return Ok(thumbnail_path);
|
||||
}
|
||||
|
||||
let workspace = create_video_workspace().await?;
|
||||
let extension = db_path.rsplit('.').next().unwrap_or("mp4");
|
||||
let source = workspace.join(format!("source.{extension}"));
|
||||
let thumbnail = workspace.join("preview.jpg");
|
||||
let result = async {
|
||||
storage.download_to_path(db_path, &source).await?;
|
||||
create_video_sprite(&source, &thumbnail).await?;
|
||||
let data = tokio::fs::read(&thumbnail)
|
||||
.await
|
||||
.map_err(|error| StorageError::new("failed to read video preview", error))?;
|
||||
storage.write(&thumbnail_path, &data).await?;
|
||||
Ok(thumbnail_path.clone())
|
||||
}
|
||||
.await;
|
||||
let _ = tokio::fs::remove_dir_all(&workspace).await;
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn ensure_media_delivery_paths(
|
||||
storage: &Storage,
|
||||
file_type: &str,
|
||||
db_path: &str,
|
||||
) -> StorageResult<MediaDeliveryPaths> {
|
||||
if file_type == "video" && supports_video_preview(db_path) {
|
||||
let thumbnail_path = ensure_video_thumbnail(storage, db_path).await?;
|
||||
return Ok(MediaDeliveryPaths {
|
||||
media_path: db_path.to_string(),
|
||||
thumbnail_path,
|
||||
});
|
||||
}
|
||||
let thumbnail_path = if file_type == "photo" && supports_thumbnail(db_path) {
|
||||
ensure_thumbnail(storage, db_path).await?
|
||||
} else {
|
||||
db_path.to_string()
|
||||
};
|
||||
Ok(MediaDeliveryPaths {
|
||||
media_path: db_path.to_string(),
|
||||
thumbnail_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn media_delivery_paths(file_type: &str, db_path: &str) -> MediaDeliveryPaths {
|
||||
let thumbnail_path = if (file_type == "photo" && supports_thumbnail(db_path))
|
||||
|| (file_type == "video" && supports_video_preview(db_path))
|
||||
{
|
||||
thumbnail_db_path(db_path)
|
||||
} else {
|
||||
db_path.to_string()
|
||||
};
|
||||
MediaDeliveryPaths {
|
||||
media_path: db_path.to_string(),
|
||||
thumbnail_path,
|
||||
}
|
||||
}
|
||||
|
||||
enum ByteRange {
|
||||
Full,
|
||||
Partial { start: u64, end: u64 },
|
||||
Unsatisfiable,
|
||||
}
|
||||
|
||||
fn parse_byte_range(header: Option<&str>, file_len: u64) -> ByteRange {
|
||||
let Some(value) = header else {
|
||||
return ByteRange::Full;
|
||||
};
|
||||
let Some(spec) = value.strip_prefix("bytes=") else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
if spec.contains(',') || file_len == 0 {
|
||||
return ByteRange::Unsatisfiable;
|
||||
}
|
||||
|
||||
let Some((start, end)) = spec.split_once('-') else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
if start.is_empty() {
|
||||
let Ok(suffix_len) = end.parse::<u64>() else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
if suffix_len == 0 {
|
||||
return ByteRange::Unsatisfiable;
|
||||
}
|
||||
let start = file_len.saturating_sub(suffix_len);
|
||||
return ByteRange::Partial {
|
||||
start,
|
||||
end: file_len - 1,
|
||||
};
|
||||
}
|
||||
|
||||
let Ok(start) = start.parse::<u64>() else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
if start >= file_len {
|
||||
return ByteRange::Unsatisfiable;
|
||||
}
|
||||
let end = if end.is_empty() {
|
||||
file_len - 1
|
||||
} else {
|
||||
let Ok(end) = end.parse::<u64>() else {
|
||||
return ByteRange::Unsatisfiable;
|
||||
};
|
||||
end.min(file_len - 1)
|
||||
};
|
||||
if end < start {
|
||||
return ByteRange::Unsatisfiable;
|
||||
}
|
||||
|
||||
ByteRange::Partial { start, end }
|
||||
}
|
||||
|
||||
/// Read a local file into an HTTP response, honoring one `Range: bytes=...` request.
|
||||
pub async fn ranged_local_file_response(
|
||||
db_path: &str,
|
||||
content_type: &str,
|
||||
range_header: Option<&str>,
|
||||
) -> std::io::Result<Response> {
|
||||
let path = resolve_db_path(db_path);
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
let file_len = file.metadata().await?.len();
|
||||
let range = parse_byte_range(range_header, file_len);
|
||||
|
||||
let (status, body, content_range) = match range {
|
||||
ByteRange::Full => {
|
||||
let mut data = Vec::with_capacity(file_len as usize);
|
||||
file.read_to_end(&mut data).await?;
|
||||
(StatusCode::OK, data, None)
|
||||
}
|
||||
ByteRange::Partial { start, end } => {
|
||||
let range_len = end - start + 1;
|
||||
let mut data = vec![0; range_len as usize];
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
file.read_exact(&mut data).await?;
|
||||
(
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
data,
|
||||
Some(format!("bytes {start}-{end}/{file_len}")),
|
||||
)
|
||||
}
|
||||
ByteRange::Unsatisfiable => {
|
||||
let mut response = Response::new(Body::fixed(Vec::<u8>::new()));
|
||||
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("accept-ranges", "bytes".parse().unwrap());
|
||||
response.headers_mut().insert(
|
||||
"content-range",
|
||||
format!("bytes */{file_len}").parse().unwrap(),
|
||||
);
|
||||
return Ok(response);
|
||||
}
|
||||
};
|
||||
|
||||
let content_len = body.len();
|
||||
let mut response = Response::new(Body::fixed(body));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("accept-ranges", "bytes".parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-length", content_len.to_string().parse().unwrap());
|
||||
if let Some(content_range) = content_range {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-range", content_range.parse().unwrap());
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn storage_error(error: StorageError) -> cot::Error {
|
||||
cot::Error::internal(error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ByteRange, R2Config, R2Storage, normalized_video_db_path, object_key, parse_byte_range,
|
||||
supports_video_preview, thumbnail_db_path,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn creates_stable_normalized_video_paths() {
|
||||
assert_eq!(
|
||||
normalized_video_db_path("uploads/1/report.mov"),
|
||||
"uploads/1/report.web.mp4"
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_video_db_path("uploads/1/report.web.mp4"),
|
||||
"uploads/1/report.web.mp4"
|
||||
);
|
||||
assert_eq!(
|
||||
thumbnail_db_path("uploads/1/report.web.mp4"),
|
||||
"uploads/1/report.web.thumb.jpg"
|
||||
);
|
||||
assert!(supports_video_preview("report.MOV"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_byte_ranges() {
|
||||
assert!(matches!(
|
||||
parse_byte_range(Some("bytes=10-19"), 100),
|
||||
ByteRange::Partial { start: 10, end: 19 }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_byte_range(Some("bytes=90-"), 100),
|
||||
ByteRange::Partial { start: 90, end: 99 }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_byte_range(Some("bytes=-10"), 100),
|
||||
ByteRange::Partial { start: 90, end: 99 }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_byte_range(Some("bytes=100-"), 100),
|
||||
ByteRange::Unsatisfiable
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_r2_fields() {
|
||||
assert!(R2Config::fields_are_valid(
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"pet-media",
|
||||
"access-key",
|
||||
"secret-key",
|
||||
));
|
||||
assert!(!R2Config::fields_are_valid(
|
||||
"not-an-account",
|
||||
"pet-media",
|
||||
"access-key",
|
||||
"secret-key",
|
||||
));
|
||||
assert!(!R2Config::fields_are_valid(
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"Invalid_Bucket",
|
||||
"access-key",
|
||||
"secret-key",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_database_paths_to_object_keys() {
|
||||
assert_eq!(object_key("uploads/1/photo.jpg"), "uploads/1/photo.jpg");
|
||||
assert_eq!(
|
||||
object_key("/data/uploads/photo.jpg"),
|
||||
"data/uploads/photo.jpg"
|
||||
);
|
||||
assert_eq!(object_key("uploads\\1\\photo.jpg"), "uploads/1/photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_r2_urls_for_six_hours() {
|
||||
let storage = R2Storage::new(R2Config {
|
||||
account_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
bucket: "pet-media".to_string(),
|
||||
access_key_id: "access-key".to_string(),
|
||||
secret_access_key: "secret-key".to_string(),
|
||||
});
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let url = runtime
|
||||
.block_on(storage.presigned_get_url("uploads/1/photo.jpg"))
|
||||
.unwrap();
|
||||
assert!(url.contains("X-Amz-Expires=21600"));
|
||||
assert!(url.contains("uploads/1/photo.jpg"));
|
||||
assert!(url.contains("pet-media"));
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use cot::db::{Database, Model, query};
|
||||
use serde_json::json;
|
||||
use web_push_native::jwt_simple::algorithms::ES256KeyPair;
|
||||
use web_push_native::p256::PublicKey;
|
||||
use web_push_native::{Auth, WebPushBuilder};
|
||||
|
||||
use crate::models::{Client, PushSubscription, Setting, Visit};
|
||||
|
||||
pub struct VapidConfig {
|
||||
pub public_key: String,
|
||||
private_key: String,
|
||||
subject: String,
|
||||
}
|
||||
|
||||
fn normalize_key_pair(public_key: &str, private_key: &str) -> Option<(String, String)> {
|
||||
let strip_assignment = |value: &str, name: &str| {
|
||||
value
|
||||
.trim()
|
||||
.strip_prefix(&format!("{name}="))
|
||||
.unwrap_or(value.trim())
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
let public_key = strip_assignment(public_key, "WEB_PETTING_VAPID_PUBLIC_KEY");
|
||||
let private_key = strip_assignment(private_key, "WEB_PETTING_VAPID_PRIVATE_KEY");
|
||||
let public_bytes = URL_SAFE_NO_PAD.decode(&public_key).ok()?;
|
||||
let private_bytes = URL_SAFE_NO_PAD.decode(&private_key).ok()?;
|
||||
|
||||
if public_bytes.len() == 65 && public_bytes.first() == Some(&4) && private_bytes.len() == 32 {
|
||||
return Some((public_key, private_key));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn load_config(db: &Database) -> Option<VapidConfig> {
|
||||
let settings = Setting::objects().all(db).await.ok()?;
|
||||
let value = |key: &str| {
|
||||
settings
|
||||
.iter()
|
||||
.find(|setting| setting.key == key)
|
||||
.map(|setting| setting.value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
};
|
||||
let raw_public_key = value("vapid_public_key")?;
|
||||
let raw_private_key = value("vapid_private_key")?;
|
||||
let (public_key, private_key) = match normalize_key_pair(&raw_public_key, &raw_private_key) {
|
||||
Some(keys) => keys,
|
||||
None => {
|
||||
tracing::warn!("invalid VAPID configuration in database");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let subject_value =
|
||||
value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string());
|
||||
let subject = subject_value
|
||||
.strip_prefix("WEB_PETTING_VAPID_SUBJECT=")
|
||||
.unwrap_or(&subject_value)
|
||||
.trim()
|
||||
.to_string();
|
||||
Some(VapidConfig {
|
||||
public_key,
|
||||
private_key,
|
||||
subject,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn initialize(db: &Database) {
|
||||
if load_config(db).await.is_some() {
|
||||
tracing::info!("VAPID configuration loaded from database");
|
||||
} else {
|
||||
tracing::info!("VAPID configuration is not set; client Web Push is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn notify_visit_completed(db: &Database, visit: &Visit) {
|
||||
let setting_key = "client_notifications_enabled".to_string();
|
||||
let enabled = query!(Setting, $key == setting_key)
|
||||
.get(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|setting| setting.value == "true")
|
||||
.unwrap_or(false);
|
||||
let Some(config) = load_config(db).await else {
|
||||
return;
|
||||
};
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let client_id = visit.client_id.primary_key().unwrap();
|
||||
let client = match query!(Client, $id == client_id).get(db).await {
|
||||
Ok(Some(client)) => client,
|
||||
_ => return,
|
||||
};
|
||||
let active = "active".to_string();
|
||||
let subscriptions = match query!(PushSubscription, $status == active).all(db).await {
|
||||
Ok(items) => items
|
||||
.into_iter()
|
||||
.filter(|item| item.client_id.primary_key().unwrap() == client_id)
|
||||
.collect::<Vec<_>>(),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "failed to load Web Push subscriptions");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut visible_visits = match Visit::objects().all(db).await {
|
||||
Ok(visits) => visits
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
item.client_id.primary_key().unwrap() == client_id
|
||||
&& item.status != "cancelled"
|
||||
&& item.status != "deleted"
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
visible_visits.sort_by(|a, b| {
|
||||
b.visit_date
|
||||
.cmp(&a.visit_date)
|
||||
.then(b.time_start.cmp(&a.time_start))
|
||||
});
|
||||
let page = visible_visits
|
||||
.iter()
|
||||
.position(|item| item.id.unwrap() == visit.id.unwrap())
|
||||
.map(|index| index / 10 + 1)
|
||||
.unwrap_or(1);
|
||||
|
||||
for mut subscription in subscriptions {
|
||||
let is_ru = subscription.language == "ru";
|
||||
let date = visit.visit_date.format("%d.%m.%Y");
|
||||
let body = if is_ru {
|
||||
format!("Визит {date} завершён. Нажмите для просмотра медиа и комментариев.")
|
||||
} else {
|
||||
format!("Visit {date} is complete. Click to view media and comments.")
|
||||
};
|
||||
let payload = json!({
|
||||
"title": if is_ru { "Визит завершён" } else { "Visit completed" },
|
||||
"body": body,
|
||||
"url": format!("/client/{}?page={}#visit-{}", client.media_token, page, visit.id.unwrap()),
|
||||
"tag": format!("visit-{}", visit.id.unwrap()),
|
||||
});
|
||||
|
||||
match send(&subscription, payload.to_string().into_bytes(), &config).await {
|
||||
Ok(status)
|
||||
if status == reqwest::StatusCode::NOT_FOUND
|
||||
|| status == reqwest::StatusCode::GONE =>
|
||||
{
|
||||
subscription.status = "archived".to_string();
|
||||
subscription.updated_at = chrono::Utc::now().naive_utc();
|
||||
if let Err(error) = subscription.save(db).await {
|
||||
tracing::warn!(%error, "failed to archive expired Web Push subscription");
|
||||
}
|
||||
}
|
||||
Ok(status) if status.is_success() => {}
|
||||
Ok(status) => tracing::warn!(%status, "Web Push gateway rejected notification"),
|
||||
Err(error) => tracing::warn!(%error, "failed to send Web Push notification"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(
|
||||
subscription: &PushSubscription,
|
||||
content: Vec<u8>,
|
||||
config: &VapidConfig,
|
||||
) -> Result<reqwest::StatusCode, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let private = URL_SAFE_NO_PAD.decode(&config.private_key)?;
|
||||
let key_pair = ES256KeyPair::from_bytes(&private)?;
|
||||
let p256dh = URL_SAFE_NO_PAD.decode(&subscription.p256dh)?;
|
||||
let auth = URL_SAFE_NO_PAD.decode(&subscription.auth)?;
|
||||
let builder = WebPushBuilder::new(
|
||||
subscription.endpoint.parse()?,
|
||||
PublicKey::from_sec1_bytes(&p256dh)?,
|
||||
Auth::clone_from_slice(&auth),
|
||||
)
|
||||
.with_vapid(&key_pair, &config.subject);
|
||||
let request = builder.build(content)?;
|
||||
let (parts, body) = request.into_parts();
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?
|
||||
.request(parts.method, parts.uri.to_string())
|
||||
.headers(parts.headers)
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(response.status())
|
||||
}
|
||||
@@ -76,11 +76,16 @@
|
||||
<form method="post" action="/admin/clients/{{ client_id }}/archive">
|
||||
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
{% else if client_status == "archived" %}
|
||||
<form method="post" action="/admin/clients/{{ client_id }}/activate">
|
||||
<button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if client_status != "deleted" %}
|
||||
<form method="post" action="/admin/clients/{{ client_id }}/delete" onsubmit="return confirm('{{ t.clients_delete_confirm }}');" style="margin-top:0.75rem;">
|
||||
<button type="submit" class="button is-danger is-outlined is-fullwidth">{{ t.clients_delete }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
{% if let Some(err) = error.as_ref() %}
|
||||
<div class="notification is-danger is-light">{{ err }}</div>
|
||||
{% endif %}
|
||||
{% if auth_sso_enabled %}
|
||||
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
|
||||
{% endif %}
|
||||
{% if auth_password_enabled %}
|
||||
{% if auth_sso_enabled %}<hr style="margin:1rem 0;">{% endif %}
|
||||
<form method="post" action="/admin/login/submit">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.users_login }}</label>
|
||||
@@ -45,10 +50,11 @@
|
||||
<div class="control"><input class="input" type="password" name="password" required></div>
|
||||
</div>
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-size="compact" style="margin-top:0.75rem;"></div>
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.75rem;"></div>
|
||||
{% endif %}
|
||||
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
+51
-12
@@ -27,12 +27,19 @@
|
||||
{% for item in &items %}
|
||||
<div class="media-card">
|
||||
{% if item.media.file_type == "photo" %}
|
||||
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ item.media.id }}" alt="" loading="lazy">
|
||||
<a href="{{ item.media.url }}" data-lightbox="photo">
|
||||
<span class="photo-thumb media-loading-frame is-loading">
|
||||
<img src="{{ item.media.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
||||
</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="video">
|
||||
<div class="video-thumb">🎬</div>
|
||||
<a href="{{ item.media.url }}" data-lightbox="video">
|
||||
<div class="video-thumb media-loading-frame{% if !item.media.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
||||
{% if !item.media.thumbnail_url.is_empty() %}
|
||||
<img class="video-preview-sprite" src="{{ item.media.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
|
||||
{% endif %}
|
||||
<span class="video-play">▶</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="media-info">
|
||||
@@ -45,13 +52,26 @@
|
||||
{% if let Some(cap) = item.media.caption.as_deref() %}
|
||||
<div style="font-size:0.82rem;color:#666;margin-top:0.2rem;">{{ cap }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/media/{{ item.media.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
|
||||
<form method="post" action="/admin/media/{{ item.media.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
|
||||
<button class="button is-small is-danger is-outlined btn-sm">{{ t.media_delete }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if total_pages > 1 %}
|
||||
<nav class="media-pagination" aria-label="Pagination">
|
||||
{% if page > 1 %}
|
||||
<a class="button is-small" href="/admin/media?lang={{ lang.code() }}&page={{ page - 1 }}{% if filter_client_id > 0 %}&client_id={{ filter_client_id }}{% endif %}">←</a>
|
||||
{% endif %}
|
||||
{% for p in 1..=total_pages %}
|
||||
<a class="button is-small{% if p == page %} is-link{% endif %}" href="/admin/media?lang={{ lang.code() }}&page={{ p }}{% if filter_client_id > 0 %}&client_id={{ filter_client_id }}{% endif %}">{{ p }}</a>
|
||||
{% endfor %}
|
||||
{% if page < total_pages %}
|
||||
<a class="button is-small" href="/admin/media?lang={{ lang.code() }}&page={{ page + 1 }}{% if filter_client_id > 0 %}&client_id={{ filter_client_id }}{% endif %}">→</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
@@ -72,18 +92,37 @@
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.media-card .video-thumb {
|
||||
.media-card .photo-thumb {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.media-card .video-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: #111;
|
||||
}
|
||||
.media-card .video-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 5px #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-info {
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.media-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.media-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -95,7 +134,7 @@
|
||||
.media-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
.media-card img, .media-card .video-thumb {
|
||||
.media-card img, .media-card .photo-thumb, .media-card .video-thumb {
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||
</div>
|
||||
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||
<div id="uploadQueue" class="upload-queue"></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -49,6 +50,7 @@
|
||||
var form = document.getElementById('uploadForm');
|
||||
var filesInput = document.getElementById('uploadFiles');
|
||||
var fileCount = document.getElementById('fileCount');
|
||||
var queue = document.getElementById('uploadQueue');
|
||||
var progress = document.getElementById('uploadProgress');
|
||||
var bar = document.getElementById('uploadBar');
|
||||
var percent = document.getElementById('uploadPercent');
|
||||
@@ -57,9 +59,41 @@
|
||||
|
||||
filesInput.addEventListener('change', function() {
|
||||
var n = this.files.length;
|
||||
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
||||
fileCount.textContent = n > 0 ? ('{{ t.media_files_selected }}: ' + n) : '';
|
||||
queue.replaceChildren();
|
||||
Array.from(this.files).forEach(function(file) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'upload-queue-item';
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'upload-queue-icon';
|
||||
icon.textContent = file.type.indexOf('video/') === 0 ? '🎬' : '🖼️';
|
||||
var details = document.createElement('div');
|
||||
var name = document.createElement('div');
|
||||
name.className = 'upload-queue-name';
|
||||
name.textContent = file.name;
|
||||
var state = document.createElement('div');
|
||||
state.className = 'upload-queue-state';
|
||||
state.textContent = '0%';
|
||||
var track = document.createElement('div');
|
||||
track.className = 'upload-queue-track';
|
||||
var itemBar = document.createElement('div');
|
||||
itemBar.className = 'upload-queue-bar';
|
||||
track.appendChild(itemBar);
|
||||
details.append(name, state, track);
|
||||
item.append(icon, details);
|
||||
queue.appendChild(item);
|
||||
});
|
||||
});
|
||||
|
||||
function updateQueue(state, progressValue, processing) {
|
||||
queue.querySelectorAll('.upload-queue-item').forEach(function(item) {
|
||||
item.querySelector('.upload-queue-state').textContent = state;
|
||||
var itemBar = item.querySelector('.upload-queue-bar');
|
||||
itemBar.classList.toggle('is-processing', processing);
|
||||
if (!processing) itemBar.style.width = progressValue + '%';
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
if (!filesInput.files.length) return;
|
||||
@@ -69,7 +103,9 @@
|
||||
|
||||
progress.style.display = 'block';
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Загрузка...';
|
||||
submitBtn.textContent = '{{ t.media_upload_sending }}';
|
||||
statusText.textContent = '{{ t.media_upload_sending }}';
|
||||
bar.classList.remove('is-processing');
|
||||
bar.style.width = '0%';
|
||||
percent.textContent = '0%';
|
||||
|
||||
@@ -78,24 +114,42 @@
|
||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||
bar.style.width = pct + '%';
|
||||
percent.textContent = pct + '%';
|
||||
if (pct === 100) statusText.textContent = 'Обработка...';
|
||||
updateQueue(pct + '%', pct, false);
|
||||
if (pct === 100) {
|
||||
statusText.textContent = '{{ t.media_upload_processing }}';
|
||||
percent.textContent = '•••';
|
||||
bar.classList.add('is-processing');
|
||||
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
||||
}
|
||||
});
|
||||
xhr.upload.addEventListener('load', function() {
|
||||
statusText.textContent = '{{ t.media_upload_processing }}';
|
||||
percent.textContent = '•••';
|
||||
bar.classList.add('is-processing');
|
||||
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', function() {
|
||||
if (xhr.status >= 200 && xhr.status < 400) {
|
||||
bar.style.width = '100%';
|
||||
bar.classList.remove('is-processing');
|
||||
percent.textContent = '100%';
|
||||
statusText.textContent = 'Готово!';
|
||||
statusText.textContent = '{{ t.media_upload_done }}';
|
||||
updateQueue('{{ t.media_upload_done }}', 100, false);
|
||||
setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300);
|
||||
} else {
|
||||
bar.classList.remove('is-processing');
|
||||
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||
updateQueue('Ошибка загрузки', 0, false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '{{ t.media_upload }}';
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', function() {
|
||||
statusText.textContent = 'Ошибка соединения';
|
||||
bar.classList.remove('is-processing');
|
||||
statusText.textContent = '{{ t.media_upload_connection_error }}';
|
||||
updateQueue('{{ t.media_upload_connection_error }}', 0, false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '{{ t.media_upload }}';
|
||||
});
|
||||
@@ -106,4 +160,17 @@
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.upload-queue { display:flex; flex-direction:column; gap:.4rem; margin-top:.65rem; }
|
||||
.upload-queue:empty { display:none; }
|
||||
.upload-queue-item { display:grid; grid-template-columns:34px minmax(0,1fr); gap:.55rem; align-items:center; padding:.45rem .55rem; background:#f7f6ff; border-radius:8px; }
|
||||
.upload-queue-icon { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:#ebe8ff; font-size:1.05rem; }
|
||||
.upload-queue-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.78rem; color:#494467; }
|
||||
.upload-queue-state { font-size:.68rem; color:#8a84a5; }
|
||||
.upload-queue-track { height:3px; overflow:hidden; border-radius:99px; background:#dedbea; margin-top:.25rem; }
|
||||
.upload-queue-bar { height:100%; width:0; background:#7567e8; transition:width .2s; }
|
||||
#uploadBar.is-processing, .upload-queue-bar.is-processing { width:35% !important; animation:upload-processing 1.15s ease-in-out infinite; }
|
||||
@keyframes upload-processing { from { transform:translateX(-110%); } to { transform:translateX(300%); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,15 +14,7 @@
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_client }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="client_id" required>
|
||||
{% for c in &clients %}
|
||||
<option value="{{ c.id }}" {% if c.id.unwrap() == visit.client_id.primary_key().unwrap() %}selected{% endif %}>
|
||||
{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<input class="input" type="text" value="{{ client.name }}{% if let Some(p) = client.phone.as_deref() %} ({{ p }}){% endif %}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,17 +113,27 @@
|
||||
{% for m in &media %}
|
||||
<div class="visit-media-item">
|
||||
{% if m.file_type == "photo" %}
|
||||
<a href="/admin/uploads/{{ m.id }}" data-lightbox="photo">
|
||||
<img src="/admin/uploads/{{ m.id }}" alt="" loading="lazy">
|
||||
<a href="{{ m.url }}" data-lightbox="photo">
|
||||
<span class="photo-thumb media-loading-frame is-loading">
|
||||
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
||||
</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/admin/uploads/{{ m.id }}" data-lightbox="video">
|
||||
<div class="video-thumb-sm">🎬</div>
|
||||
<a href="{{ m.url }}" data-lightbox="video">
|
||||
<div class="video-thumb-sm media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
||||
{% if !m.thumbnail_url.is_empty() %}
|
||||
<img class="video-preview-sprite" src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
|
||||
{% endif %}
|
||||
<span class="video-play">▶</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if let Some(cap) = m.caption.as_deref() %}
|
||||
<div class="media-cap">{{ cap }}</div>
|
||||
{% endif %}
|
||||
<div class="visit-media-delete">
|
||||
<button type="submit" form="visit-media-delete-{{ m.id.unwrap() }}" class="button is-small is-danger is-outlined">{{ t.media_delete }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -141,6 +143,9 @@
|
||||
|
||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
||||
</form>
|
||||
{% for m in &media %}
|
||||
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
|
||||
{% endfor %}
|
||||
|
||||
<hr style="margin:1rem 0;">
|
||||
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
||||
@@ -162,6 +167,7 @@
|
||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||
</div>
|
||||
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||
<div id="uploadQueue" class="upload-queue"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.media_caption }}</label>
|
||||
@@ -237,14 +243,26 @@
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.visit-media-item .video-thumb-sm {
|
||||
.visit-media-item .photo-thumb {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.visit-media-item .video-thumb-sm {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: #111;
|
||||
}
|
||||
.visit-media-item .video-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 4px #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.visit-media-item .media-cap {
|
||||
font-size: 0.7rem;
|
||||
@@ -254,6 +272,14 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.visit-media-delete {
|
||||
padding: 0.25rem 0.4rem 0.4rem;
|
||||
}
|
||||
.visit-media-delete .button {
|
||||
width: 100%;
|
||||
font-size: 0.68rem;
|
||||
min-height: 1.65rem;
|
||||
}
|
||||
.upload-modal-bg {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -274,6 +300,16 @@
|
||||
max-width: 420px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
.upload-queue { display:flex; flex-direction:column; gap:.4rem; margin-top:.65rem; }
|
||||
.upload-queue:empty { display:none; }
|
||||
.upload-queue-item { display:grid; grid-template-columns:34px minmax(0,1fr); gap:.55rem; align-items:center; padding:.45rem .55rem; background:#f7f6ff; border-radius:8px; }
|
||||
.upload-queue-icon { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:#ebe8ff; font-size:1.05rem; }
|
||||
.upload-queue-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.78rem; color:#494467; }
|
||||
.upload-queue-state { font-size:.68rem; color:#8a84a5; }
|
||||
.upload-queue-track { height:3px; overflow:hidden; border-radius:99px; background:#dedbea; margin-top:.25rem; }
|
||||
.upload-queue-bar { height:100%; width:0; background:#7567e8; transition:width .2s; }
|
||||
#uploadBar.is-processing, .upload-queue-bar.is-processing { width:35% !important; animation:upload-processing 1.15s ease-in-out infinite; }
|
||||
@keyframes upload-processing { from { transform:translateX(-110%); } to { transform:translateX(300%); } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -293,6 +329,7 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||
var form = document.getElementById('uploadForm');
|
||||
var filesInput = document.getElementById('uploadFiles');
|
||||
var fileCount = document.getElementById('fileCount');
|
||||
var queue = document.getElementById('uploadQueue');
|
||||
var progress = document.getElementById('uploadProgress');
|
||||
var bar = document.getElementById('uploadBar');
|
||||
var percent = document.getElementById('uploadPercent');
|
||||
@@ -313,9 +350,41 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||
// Show selected file count
|
||||
filesInput.addEventListener('change', function() {
|
||||
var n = this.files.length;
|
||||
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
||||
fileCount.textContent = n > 0 ? ('{{ t.media_files_selected }}: ' + n) : '';
|
||||
queue.replaceChildren();
|
||||
Array.from(this.files).forEach(function(file) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'upload-queue-item';
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'upload-queue-icon';
|
||||
icon.textContent = file.type.indexOf('video/') === 0 ? '🎬' : '🖼️';
|
||||
var details = document.createElement('div');
|
||||
var name = document.createElement('div');
|
||||
name.className = 'upload-queue-name';
|
||||
name.textContent = file.name;
|
||||
var state = document.createElement('div');
|
||||
state.className = 'upload-queue-state';
|
||||
state.textContent = '0%';
|
||||
var track = document.createElement('div');
|
||||
track.className = 'upload-queue-track';
|
||||
var itemBar = document.createElement('div');
|
||||
itemBar.className = 'upload-queue-bar';
|
||||
track.appendChild(itemBar);
|
||||
details.append(name, state, track);
|
||||
item.append(icon, details);
|
||||
queue.appendChild(item);
|
||||
});
|
||||
});
|
||||
|
||||
function updateQueue(state, progressValue, processing) {
|
||||
queue.querySelectorAll('.upload-queue-item').forEach(function(item) {
|
||||
item.querySelector('.upload-queue-state').textContent = state;
|
||||
var itemBar = item.querySelector('.upload-queue-bar');
|
||||
itemBar.classList.toggle('is-processing', processing);
|
||||
if (!processing) itemBar.style.width = progressValue + '%';
|
||||
});
|
||||
}
|
||||
|
||||
// Submit via XHR for progress tracking
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
@@ -327,7 +396,9 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||
// Show progress bar, disable submit
|
||||
progress.style.display = 'block';
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Загрузка...';
|
||||
submitBtn.textContent = '{{ t.media_upload_sending }}';
|
||||
statusText.textContent = '{{ t.media_upload_sending }}';
|
||||
bar.classList.remove('is-processing');
|
||||
bar.style.width = '0%';
|
||||
percent.textContent = '0%';
|
||||
|
||||
@@ -336,25 +407,43 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||
bar.style.width = pct + '%';
|
||||
percent.textContent = pct + '%';
|
||||
if (pct === 100) statusText.textContent = 'Обработка...';
|
||||
updateQueue(pct + '%', pct, false);
|
||||
if (pct === 100) {
|
||||
statusText.textContent = '{{ t.media_upload_processing }}';
|
||||
percent.textContent = '•••';
|
||||
bar.classList.add('is-processing');
|
||||
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
||||
}
|
||||
});
|
||||
xhr.upload.addEventListener('load', function() {
|
||||
statusText.textContent = '{{ t.media_upload_processing }}';
|
||||
percent.textContent = '•••';
|
||||
bar.classList.add('is-processing');
|
||||
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', function() {
|
||||
if (xhr.status >= 200 && xhr.status < 400) {
|
||||
bar.style.width = '100%';
|
||||
bar.classList.remove('is-processing');
|
||||
percent.textContent = '100%';
|
||||
statusText.textContent = 'Готово!';
|
||||
statusText.textContent = '{{ t.media_upload_done }}';
|
||||
updateQueue('{{ t.media_upload_done }}', 100, false);
|
||||
// Reload page to show uploaded media
|
||||
setTimeout(function() { window.location.reload(); }, 300);
|
||||
} else {
|
||||
bar.classList.remove('is-processing');
|
||||
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||
updateQueue('Ошибка загрузки', 0, false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '{{ t.media_upload }}';
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', function() {
|
||||
statusText.textContent = 'Ошибка соединения';
|
||||
bar.classList.remove('is-processing');
|
||||
statusText.textContent = '{{ t.media_upload_connection_error }}';
|
||||
updateQueue('{{ t.media_upload_connection_error }}', 0, false);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '{{ t.media_upload }}';
|
||||
});
|
||||
|
||||
+182
-32
@@ -11,15 +11,14 @@
|
||||
{% if saved %}
|
||||
<div class="notification is-success is-light">{{ t.settings_saved }}</div>
|
||||
{% endif %}
|
||||
{% if let Some(message) = error %}
|
||||
<div class="notification is-danger is-light">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-card">
|
||||
<form method="post" action="/admin/settings/save">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_telegram_bot_token }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_contact_info }}</label>
|
||||
<div class="control">
|
||||
@@ -32,18 +31,6 @@
|
||||
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_site_domain }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_timezone }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_seo_keywords }}</label>
|
||||
<div class="control">
|
||||
@@ -52,23 +39,186 @@
|
||||
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
|
||||
<p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_site_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<details style="margin-top:1.5rem;">
|
||||
<summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
|
||||
{{ t.settings_section_advanced }}
|
||||
</summary>
|
||||
|
||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
||||
<div style="margin-top:1rem;">
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_site_domain }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_timezone }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_storage }}</h3>
|
||||
<div class="notification is-info is-light" style="padding:0.8rem 1rem;font-size:0.85rem;margin-bottom:0.85rem;">
|
||||
<p>{{ t.settings_r2_help }}</p>
|
||||
<p style="margin-top:0.45rem;">{{ t.settings_r2_migration_help }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="r2_enabled" value="true"{% if r2_enabled_checked %} checked{% endif %}>
|
||||
{{ t.settings_r2_enabled }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_r2_account_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="r2_account_id" maxlength="32" autocomplete="off" placeholder="0123456789abcdef0123456789abcdef" value="{% for s in &settings %}{% if s.key == "r2_account_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_r2_bucket }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="r2_bucket" autocomplete="off" placeholder="pet-media" value="{% for s in &settings %}{% if s.key == "r2_bucket" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_r2_access_key_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="r2_access_key_id" autocomplete="off" value="{% for s in &settings %}{% if s.key == "r2_access_key_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_r2_secret_access_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="r2_secret_access_key" autocomplete="new-password"{% if r2_secret_configured %} placeholder="{{ t.settings_r2_secret_unchanged }}"{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="client_notifications_enabled" value="true"{% if client_notifications_checked %} checked{% endif %}>
|
||||
{{ t.settings_client_notifications_enabled }}
|
||||
</label>
|
||||
<p class="help">{{ t.settings_client_notifications_help }}</p>
|
||||
</div>
|
||||
<blockquote class="notification is-warning is-light" style="padding:0.8rem 1rem;font-size:0.85rem;margin:0.75rem 0;">
|
||||
<p>{{ t.settings_vapid_warning }}</p>
|
||||
<p style="margin-top:0.45rem;">{{ t.settings_vapid_generate }}</p>
|
||||
<code style="display:inline-block;margin-top:0.2rem;user-select:all;">cargo run --bin generate_vapid</code>
|
||||
</blockquote>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_vapid_public_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="vapid_public_key" autocomplete="off" value="{% for s in &settings %}{% if s.key == "vapid_public_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_vapid_private_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="vapid_private_key" autocomplete="new-password" value="{% for s in &settings %}{% if s.key == "vapid_private_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_vapid_subject }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="vapid_subject" placeholder="mailto:admin@example.com" value="{% for s in &settings %}{% if s.key == "vapid_subject" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<details style="margin:0.9rem 0 1.25rem;border:1px solid #e8e5f5;border-radius:8px;background:#faf9ff;">
|
||||
<summary style="cursor:pointer;padding:0.7rem 0.85rem;font-weight:600;font-size:0.9rem;">
|
||||
{{ t.settings_push_subscribers }} ({{ push_subscribers.len() }})
|
||||
</summary>
|
||||
<div style="padding:0 0.85rem 0.85rem;overflow-x:auto;">
|
||||
{% if push_subscribers.is_empty() %}
|
||||
<p class="help">{{ t.settings_push_no_subscribers }}</p>
|
||||
{% else %}
|
||||
<table class="table is-fullwidth is-striped is-narrow" style="font-size:0.8rem;background:transparent;">
|
||||
<thead><tr>
|
||||
<th>{{ t.settings_push_client }}</th>
|
||||
<th>{{ t.settings_push_devices }}</th>
|
||||
<th>{{ t.settings_push_language }}</th>
|
||||
<th>{{ t.settings_push_updated }}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for subscriber in &push_subscribers %}
|
||||
<tr>
|
||||
<td>{{ subscriber.client_name }}</td>
|
||||
<td>{{ subscriber.device_count }}</td>
|
||||
<td>{{ subscriber.languages }}</td>
|
||||
<td style="white-space:nowrap;">{{ subscriber.last_updated }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_telegram_bot_token }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_site_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_client_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_client_secret }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
|
||||
{{ t.settings_auth_password_enabled }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
|
||||
{{ t.settings_auth_sso_enabled }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
<div class="tm-view" id="view-{{ item.id.unwrap() }}">
|
||||
<div class="item-card-header">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
{% if item.image_path.is_some() %}
|
||||
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
||||
{% if let Some(image_url) = item.image_url.as_deref() %}
|
||||
<img src="{{ image_url }}" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
||||
{% endif %}
|
||||
<div>
|
||||
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
|
||||
@@ -91,7 +91,7 @@
|
||||
{% if item.image_path.is_some() %}
|
||||
<div class="field">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;">
|
||||
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
||||
{% if let Some(image_url) = item.image_url.as_deref() %}<img src="{{ image_url }}" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">{% endif %}
|
||||
<label style="font-size:0.85rem;cursor:pointer;color:#888;">
|
||||
<input type="checkbox" name="remove_image" value="1"> {{ t.testimonials_remove_image }}
|
||||
</label>
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.portal_title }} — {{ client.name }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="manifest" href="/client/{{ client.media_token }}/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#7c6cff">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
@@ -17,12 +20,15 @@
|
||||
padding: 0 0 2rem;
|
||||
}
|
||||
.portal-header {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #7c6cff, #b06cff);
|
||||
color: #fff; padding: 2rem 1.5rem 1.5rem; text-align: center;
|
||||
}
|
||||
.portal-header h1 { font-size: 1.5rem; font-weight: 700; }
|
||||
.portal-header .sub { opacity: 0.85; font-size: 0.9rem; margin-top: 0.25rem; }
|
||||
.container { max-width: 700px; margin: 0 auto; padding: 0 1rem; }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 0 1rem; }
|
||||
.portal-grid { display: grid; grid-template-columns: minmax(0, 700px) 320px; gap: 1.25rem; align-items: start; }
|
||||
.portal-settings { position: absolute; right: 1rem; bottom: 1rem; width: 38px; height: 38px; border: 0; border-radius: 50%; background: rgba(255,255,255,.2); color: #fff; font-size: 1.1rem; cursor: pointer; }
|
||||
.section-title {
|
||||
font-size: 1.15rem; font-weight: 700; margin: 1.5rem 0 0.75rem;
|
||||
padding-bottom: 0.4rem; border-bottom: 2px solid #ede7f6;
|
||||
@@ -51,9 +57,20 @@
|
||||
.media-row img {
|
||||
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
|
||||
}
|
||||
.media-row .media-thumb-frame {
|
||||
width: 80px; height: 60px; border-radius: 6px;
|
||||
}
|
||||
.media-row .vid-thumb {
|
||||
width: 80px; height: 60px; border-radius: 6px; background: #f0f0f0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.5rem;
|
||||
position: relative; width: 80px; height: 60px; border-radius: 6px;
|
||||
overflow: hidden; background: #111;
|
||||
}
|
||||
.media-row .vid-thumb video {
|
||||
width: 100%; height: 100%; display: block; object-fit: cover;
|
||||
}
|
||||
.media-row .video-play {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
color: white; font-size: 1.35rem; line-height: 1;
|
||||
text-shadow: 0 1px 4px #000; pointer-events: none;
|
||||
}
|
||||
.feedback-form { margin-top: 0.6rem; }
|
||||
.feedback-form textarea {
|
||||
@@ -102,6 +119,33 @@
|
||||
font-weight: 700; min-width: 5.5rem;
|
||||
}
|
||||
.upcoming-row .up-time { color: #7a7599; }
|
||||
.calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; }
|
||||
.calendar-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .65rem; }
|
||||
.calendar-head button { border: 0; background: #f0edff; color: #6255c7; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; }
|
||||
.calendar-title { font-size: .95rem; font-weight: 700; }
|
||||
.calendar-weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; text-align: center; }
|
||||
.calendar-weekdays { color: #999; font-size: .68rem; margin-bottom: 3px; }
|
||||
.calendar-day { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 7px; font-size: .78rem; color: #aaa; }
|
||||
.calendar-day.past { background: #ede9ff; color: #5145a6; font-weight: 700; text-decoration: none; }
|
||||
.calendar-day.past:hover { background: #dcd5ff; }
|
||||
.calendar-day.future { background: #f3f3f3; color: #bbb; border: 1px dashed #ddd; }
|
||||
.calendar-month[hidden] { display: none; }
|
||||
.calendar-legend { margin-top: .65rem; font-size: .72rem; color: #999; }
|
||||
.pagination { display: flex; justify-content: center; align-items: center; gap: .75rem; margin: 1rem 0; font-size: .85rem; }
|
||||
.pagination a { color: #6558c8; text-decoration: none; padding: .35rem .7rem; background: #fff; border: 1px solid #e5e1ff; border-radius: 8px; }
|
||||
.modal-bg { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(20,18,40,.55); align-items: center; justify-content: center; padding: 1rem; }
|
||||
.modal-bg.open { display: flex; }
|
||||
.notification-modal { width: min(420px, 100%); background: #fff; border-radius: 14px; padding: 1.2rem; box-shadow: 0 15px 50px rgba(0,0,0,.25); }
|
||||
.notification-modal h2 { font-size: 1.15rem; margin-bottom: .45rem; }
|
||||
.notification-modal p { font-size: .88rem; color: #777; }
|
||||
.notification-actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||
.notification-actions button { border: 0; border-radius: 8px; padding: .55rem .9rem; cursor: pointer; }
|
||||
.notification-primary { background: #7567e8; color: #fff; }
|
||||
@media (max-width: 800px) {
|
||||
.portal-grid { display: flex; flex-direction: column; }
|
||||
.calendar-panel { position: static; order: -1; width: 100%; margin-top: 1rem; }
|
||||
.visits-column { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -113,6 +157,7 @@
|
||||
<div class="portal-header">
|
||||
<h1>{{ t.portal_title }}</h1>
|
||||
<div class="sub">{{ client.name }}</div>
|
||||
{% if notifications_enabled %}<button class="portal-settings" type="button" onclick="openNotificationSettings()" aria-label="{{ t.portal_notifications }}">⚙</button>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
@@ -121,13 +166,15 @@
|
||||
<div class="success-msg">{{ t.portal_feedback_thanks }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="portal-grid">
|
||||
<main class="visits-column">
|
||||
<!-- Past visits with media first -->
|
||||
<h2 class="section-title">{{ t.portal_past }}</h2>
|
||||
{% if past.is_empty() %}
|
||||
<p class="empty-msg">{{ t.portal_no_past }}</p>
|
||||
{% else %}
|
||||
{% for pv in &past %}
|
||||
<div class="visit-card">
|
||||
<div class="visit-card" id="visit-{{ pv.visit.id.unwrap() }}">
|
||||
<div class="visit-card-head">
|
||||
<span class="date">{{ pv.visit.visit_date }}</span>
|
||||
<span class="badge-sm badge-{{ pv.visit.status }}">
|
||||
@@ -147,12 +194,19 @@
|
||||
<div class="media-row">
|
||||
{% for m in &pv.media %}
|
||||
{% if m.file_type == "photo" %}
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="photo">
|
||||
<img src="/client/{{ client.media_token }}/media/{{ m.id }}" alt="" loading="lazy">
|
||||
<a href="{{ m.url }}" data-lightbox="photo">
|
||||
<span class="media-thumb-frame media-loading-frame is-loading">
|
||||
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
||||
</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="video">
|
||||
<div class="vid-thumb">🎬</div>
|
||||
<a href="{{ m.url }}" data-lightbox="video">
|
||||
<div class="vid-thumb media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
||||
{% if !m.thumbnail_url.is_empty() %}
|
||||
<img class="video-preview-sprite" src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
|
||||
{% endif %}
|
||||
<span class="video-play">▶</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
@@ -189,6 +243,13 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if total_pages > 1 %}
|
||||
<nav class="pagination">
|
||||
{% if has_previous_page %}<a href="?page={{ page - 1 }}">← {{ t.portal_previous }}</a>{% endif %}
|
||||
<span>{{ page }} / {{ total_pages }}</span>
|
||||
{% if has_next_page %}<a href="?page={{ page + 1 }}">{{ t.portal_next }} →</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Compact upcoming schedule -->
|
||||
@@ -198,14 +259,56 @@
|
||||
{% for pv in &upcoming %}
|
||||
<div class="upcoming-row">
|
||||
<span class="up-date">{{ pv.visit.visit_date }}</span>
|
||||
<span class="up-time">{{ pv.visit.time_start }} — {{ pv.visit.time_end }}</span>
|
||||
<span class="up-time">{{ t.portal_future_visit }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
{% if !calendar_months.is_empty() %}
|
||||
<aside class="calendar-panel">
|
||||
<div class="calendar-head">
|
||||
<button type="button" onclick="moveCalendar(1)" aria-label="{{ t.portal_previous }}">‹</button>
|
||||
<span class="calendar-title" id="calendarTitle">{{ t.portal_calendar }}</span>
|
||||
<button type="button" onclick="moveCalendar(-1)" aria-label="{{ t.portal_next }}">›</button>
|
||||
</div>
|
||||
<div class="calendar-weekdays"><span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span></div>
|
||||
{% for month in &calendar_months %}
|
||||
<div class="calendar-month" data-label="{{ month.label }}"{% if !loop.first %} hidden{% endif %}>
|
||||
<div class="calendar-days">
|
||||
{% for _blank in &month.leading_blanks %}<span></span>{% endfor %}
|
||||
{% for day in &month.days %}
|
||||
{% if let Some(href) = day.href.as_deref() %}
|
||||
<a class="calendar-day {{ day.class_name }}" href="{{ href }}" title="{{ day.title }}">{{ day.number }}</a>
|
||||
{% else %}
|
||||
<span class="calendar-day {{ day.class_name }}" title="{{ day.title }}">{{ day.number }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="calendar-legend">{{ t.portal_future_visit }} — ···</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if notifications_enabled %}
|
||||
<div class="modal-bg" id="notificationModal" onclick="if(event.target===this) closeNotificationSettings()">
|
||||
<div class="notification-modal">
|
||||
<h2>{{ t.portal_notifications }}</h2>
|
||||
<p id="notificationText">{{ t.portal_notifications_text }}</p>
|
||||
<p id="notificationStatus" style="display:none;margin-top:0.65rem;font-weight:600;"></p>
|
||||
<div class="notification-actions">
|
||||
<button type="button" class="notification-primary" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
|
||||
<button type="button" onclick="closeNotificationSettings()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function showFbEdit(id) {
|
||||
document.getElementById('fb-view-' + id).style.display = 'none';
|
||||
@@ -215,6 +318,120 @@ function hideFbEdit(id) {
|
||||
document.getElementById('fb-form-' + id).style.display = 'none';
|
||||
document.getElementById('fb-view-' + id).style.display = '';
|
||||
}
|
||||
var calendarIndex = 0;
|
||||
function renderCalendar() {
|
||||
var months = document.querySelectorAll('.calendar-month');
|
||||
if (!months.length) return;
|
||||
months.forEach(function(month, index) { month.hidden = index !== calendarIndex; });
|
||||
document.getElementById('calendarTitle').textContent = months[calendarIndex].dataset.label;
|
||||
}
|
||||
function moveCalendar(delta) {
|
||||
var months = document.querySelectorAll('.calendar-month');
|
||||
calendarIndex = Math.max(0, Math.min(months.length - 1, calendarIndex + delta));
|
||||
renderCalendar();
|
||||
}
|
||||
renderCalendar();
|
||||
{% if notifications_enabled %}
|
||||
(function() {
|
||||
var toggle = document.getElementById('notificationToggle');
|
||||
var registration;
|
||||
var subscription;
|
||||
var status = document.getElementById('notificationStatus');
|
||||
function decodeKey(value) {
|
||||
var padding = '='.repeat((4 - value.length % 4) % 4);
|
||||
var raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return Uint8Array.from(raw, function(char) { return char.charCodeAt(0); });
|
||||
}
|
||||
function sameKey(left, right) {
|
||||
if (!left || left.byteLength !== right.byteLength) return false;
|
||||
var a = new Uint8Array(left), b = new Uint8Array(right);
|
||||
return a.every(function(value, index) { return value === b[index]; });
|
||||
}
|
||||
function showStatus(message, error) {
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
status.style.color = error ? '#b42318' : '#067647';
|
||||
}
|
||||
async function saveSubscription(value) {
|
||||
var payload = value.toJSON();
|
||||
payload.language = '{{ lang.code() }}';
|
||||
var response = await fetch('/client/{{ client.media_token }}/push/subscribe', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) throw new Error('Subscription API returned HTTP ' + response.status);
|
||||
}
|
||||
async function refresh() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
|
||||
toggle.disabled = true;
|
||||
showStatus('{{ t.portal_notifications_unsupported }}', true);
|
||||
return;
|
||||
}
|
||||
if (!'{{ vapid_public_key }}') {
|
||||
toggle.disabled = true;
|
||||
showStatus('{{ t.portal_notifications_error }}', true);
|
||||
return;
|
||||
}
|
||||
registration = await navigator.serviceWorker.register('/service-worker.js');
|
||||
await registration.update();
|
||||
await navigator.serviceWorker.ready;
|
||||
subscription = await registration.pushManager.getSubscription();
|
||||
var expectedKey = decodeKey('{{ vapid_public_key }}');
|
||||
if (subscription && !sameKey(subscription.options.applicationServerKey, expectedKey)) {
|
||||
await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
}
|
||||
if (subscription) {
|
||||
await saveSubscription(subscription);
|
||||
showStatus('{{ t.portal_notifications_active }}', false);
|
||||
}
|
||||
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
|
||||
}
|
||||
window.openNotificationSettings = function() {
|
||||
document.getElementById('notificationModal').classList.add('open');
|
||||
refresh().catch(function(error) {
|
||||
console.error(error);
|
||||
showStatus('{{ t.portal_notifications_error }}', true);
|
||||
});
|
||||
};
|
||||
window.closeNotificationSettings = function() { document.getElementById('notificationModal').classList.remove('open'); };
|
||||
toggle.addEventListener('click', async function() {
|
||||
toggle.disabled = true;
|
||||
try {
|
||||
if (!registration) await refresh();
|
||||
if (subscription) {
|
||||
await fetch('/client/{{ client.media_token }}/push/unsubscribe', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({endpoint: subscription.endpoint})
|
||||
});
|
||||
await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
} else {
|
||||
var permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
document.getElementById('notificationText').textContent = '{{ t.portal_notifications_denied }}';
|
||||
return;
|
||||
}
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeKey('{{ vapid_public_key }}')
|
||||
});
|
||||
await saveSubscription(subscription);
|
||||
showStatus('{{ t.portal_notifications_active }}', false);
|
||||
}
|
||||
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showStatus('{{ t.portal_notifications_error }}', true);
|
||||
} finally {
|
||||
toggle.disabled = false;
|
||||
}
|
||||
});
|
||||
refresh().catch(function(error) {
|
||||
console.error(error);
|
||||
showStatus('{{ t.portal_notifications_error }}', true);
|
||||
});
|
||||
})();
|
||||
{% endif %}
|
||||
</script>
|
||||
{% include "partials/lightbox.html" %}
|
||||
</body>
|
||||
|
||||
@@ -426,8 +426,8 @@
|
||||
<div class="testimonial-text">{{ item.text }}</div>
|
||||
{% if item.image_path.is_some() || item.author_note.is_some() %}
|
||||
<div class="testimonial-footer">
|
||||
{% if item.image_path.is_some() %}
|
||||
<img class="testimonial-avatar" src="/testimonial-image/{{ item.id.unwrap() }}" alt="">
|
||||
{% if let Some(image_url) = item.image_url.as_deref() %}
|
||||
<img class="testimonial-avatar" src="{{ image_url }}" alt="">
|
||||
{% endif %}
|
||||
{% if let Some(note) = item.author_note.as_deref() %}
|
||||
<span class="testimonial-note">{{ note }}</span>
|
||||
|
||||
@@ -1,49 +1,164 @@
|
||||
<div class="lightbox-overlay" id="lightbox" onclick="closeLightbox(event)">
|
||||
<button class="lightbox-close" onclick="closeLightbox(event)">×</button>
|
||||
<img id="lightboxImg" src="" alt="">
|
||||
<video id="lightboxVideo" controls style="display:none;"></video>
|
||||
<button class="lightbox-close" type="button" onclick="closeLightbox(event)">×</button>
|
||||
<div class="lightbox-stage" id="lightboxStage">
|
||||
<span class="lightbox-loader" aria-hidden="true"></span>
|
||||
<img id="lightboxImg" src="" alt="">
|
||||
<video id="lightboxVideo" controls playsinline preload="auto" style="display:none;"></video>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.media-loading-frame {
|
||||
position: relative; display: block; overflow: hidden; background: #eceaf5;
|
||||
}
|
||||
.media-loading-frame::after, .lightbox-loader {
|
||||
content: ""; position: absolute; z-index: 3; top: 50%; left: 50%;
|
||||
width: 22px; height: 22px; margin: -11px 0 0 -11px;
|
||||
border: 3px solid rgba(124,108,255,.22); border-top-color: #7c6cff;
|
||||
border-radius: 50%; animation: media-spinner .75s linear infinite;
|
||||
}
|
||||
.media-loading-frame:not(.is-loading)::after { display: none; }
|
||||
.media-loading-frame img[data-media-load] { opacity: 0; transition: opacity .18s ease; }
|
||||
.media-loading-frame.is-loaded img[data-media-load] { opacity: 1; }
|
||||
.media-loading-frame.is-error::after {
|
||||
display: block; content: "!"; width: 24px; height: 24px; margin: -12px 0 0 -12px;
|
||||
border: 0; animation: none; color: #9b93bb; font-weight: 700; text-align: center;
|
||||
}
|
||||
.video-preview-sprite {
|
||||
display: block !important; width: 400% !important; max-width: none !important;
|
||||
height: 100% !important; object-fit: fill !important;
|
||||
transform: translateX(0); transition: transform .08s linear;
|
||||
}
|
||||
@keyframes media-spinner { to { transform: rotate(360deg); } }
|
||||
|
||||
.lightbox-overlay {
|
||||
display:none; position:fixed; inset:0; z-index:200;
|
||||
background:rgba(0,0,0,0.85); align-items:center; justify-content:center;
|
||||
background:rgba(9,8,18,.9); align-items:center; justify-content:center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.lightbox-overlay.is-open { display:flex; }
|
||||
.lightbox-overlay img, .lightbox-overlay video {
|
||||
max-width:92vw; max-height:88vh; border-radius:8px; object-fit:contain;
|
||||
.lightbox-stage {
|
||||
position: relative; display: flex; align-items: center; justify-content: center;
|
||||
min-width: 96px; min-height: 96px; max-width: 94vw; max-height: 90vh;
|
||||
}
|
||||
.lightbox-stage img, .lightbox-stage video {
|
||||
max-width:92vw; max-height:88vh; border-radius:10px; object-fit:contain;
|
||||
background:#090909; box-shadow:0 18px 60px rgba(0,0,0,.42);
|
||||
}
|
||||
.lightbox-stage.is-loading img, .lightbox-stage.is-loading video { opacity: .18; }
|
||||
.lightbox-stage:not(.is-loading) .lightbox-loader { display: none; }
|
||||
.lightbox-stage.is-error .lightbox-loader {
|
||||
display: block; animation: none; border: 0; color: white;
|
||||
}
|
||||
.lightbox-stage.is-error .lightbox-loader::after { content: "!"; font-size: 2rem; }
|
||||
.lightbox-close {
|
||||
position:absolute; top:0.75rem; right:1rem; background:none; border:none;
|
||||
position:absolute; top:0.75rem; right:1rem; background:rgba(0,0,0,.25); border:none;
|
||||
color:#fff; font-size:2.2rem; cursor:pointer; line-height:1; z-index:201;
|
||||
width:44px; height:44px; border-radius:50%;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
function finishThumbnail(image, loaded) {
|
||||
var frame = image.closest('.media-loading-frame');
|
||||
if (!frame) return;
|
||||
frame.classList.remove('is-loading');
|
||||
frame.classList.add(loaded ? 'is-loaded' : 'is-error');
|
||||
}
|
||||
|
||||
document.querySelectorAll('img[data-media-load]').forEach(function(image) {
|
||||
image.addEventListener('load', function() { finishThumbnail(image, true); });
|
||||
image.addEventListener('error', function() { finishThumbnail(image, false); });
|
||||
if (image.complete) finishThumbnail(image, image.naturalWidth > 0);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-video-preview]').forEach(function(preview) {
|
||||
var sprite = preview.querySelector('[data-preview-frames]');
|
||||
if (!sprite) return;
|
||||
var timer;
|
||||
var frame = 0;
|
||||
var frames = Number(sprite.dataset.previewFrames) || 4;
|
||||
function showFrame() {
|
||||
sprite.style.transform = 'translateX(-' + (frame * 100 / frames) + '%)';
|
||||
}
|
||||
function start() {
|
||||
if (timer || !preview.classList.contains('is-loaded')) return;
|
||||
frame = 1;
|
||||
showFrame();
|
||||
timer = window.setInterval(function() {
|
||||
frame = (frame + 1) % frames;
|
||||
showFrame();
|
||||
}, 650);
|
||||
}
|
||||
function stop() {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
frame = 0;
|
||||
showFrame();
|
||||
}
|
||||
preview.addEventListener('pointerenter', start);
|
||||
preview.addEventListener('pointerleave', stop);
|
||||
preview.closest('a').addEventListener('focus', start);
|
||||
preview.closest('a').addEventListener('blur', stop);
|
||||
});
|
||||
})();
|
||||
|
||||
function setLightboxState(state) {
|
||||
var stage = document.getElementById('lightboxStage');
|
||||
stage.classList.remove('is-loading', 'is-error');
|
||||
if (state) stage.classList.add(state);
|
||||
}
|
||||
function openLightbox(url, isVideo) {
|
||||
var lb = document.getElementById('lightbox');
|
||||
var img = document.getElementById('lightboxImg');
|
||||
var vid = document.getElementById('lightboxVideo');
|
||||
setLightboxState('is-loading');
|
||||
lb.classList.add('is-open');
|
||||
if (isVideo) {
|
||||
img.style.display = 'none';
|
||||
img.removeAttribute('src');
|
||||
vid.style.display = '';
|
||||
vid.src = url;
|
||||
vid.load();
|
||||
var playback = vid.play();
|
||||
if (playback) playback.catch(function() {});
|
||||
} else {
|
||||
vid.style.display = 'none';
|
||||
vid.pause && vid.pause(); vid.src = '';
|
||||
vid.pause();
|
||||
vid.removeAttribute('src');
|
||||
vid.load();
|
||||
img.style.display = '';
|
||||
img.src = url;
|
||||
}
|
||||
lb.classList.add('is-open');
|
||||
}
|
||||
function closeLightbox(e) {
|
||||
if (e && e.target !== document.getElementById('lightbox') && e.target.className !== 'lightbox-close') return;
|
||||
function closeLightbox(event) {
|
||||
var lb = document.getElementById('lightbox');
|
||||
if (event && event.target !== lb && !event.target.closest('.lightbox-close')) return;
|
||||
lb.classList.remove('is-open');
|
||||
var vid = document.getElementById('lightboxVideo');
|
||||
vid.pause && vid.pause(); vid.src = '';
|
||||
vid.pause();
|
||||
vid.removeAttribute('src');
|
||||
vid.load();
|
||||
var img = document.getElementById('lightboxImg');
|
||||
img.removeAttribute('src');
|
||||
setLightboxState(null);
|
||||
}
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeLightbox(null); });
|
||||
document.addEventListener('click', function(e) {
|
||||
var a = e.target.closest('[data-lightbox]');
|
||||
if (a) { e.preventDefault(); openLightbox(a.href, a.dataset.lightbox === 'video'); }
|
||||
document.getElementById('lightboxImg').addEventListener('load', function() { setLightboxState(null); });
|
||||
document.getElementById('lightboxImg').addEventListener('error', function() { setLightboxState('is-error'); });
|
||||
var lightboxVideo = document.getElementById('lightboxVideo');
|
||||
['loadeddata', 'canplay', 'playing'].forEach(function(name) {
|
||||
lightboxVideo.addEventListener(name, function() { setLightboxState(null); });
|
||||
});
|
||||
['waiting', 'stalled', 'seeking'].forEach(function(name) {
|
||||
lightboxVideo.addEventListener(name, function() { setLightboxState('is-loading'); });
|
||||
});
|
||||
lightboxVideo.addEventListener('error', function() { setLightboxState('is-error'); });
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') closeLightbox(null);
|
||||
});
|
||||
document.addEventListener('click', function(event) {
|
||||
var link = event.target.closest('[data-lightbox]');
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
openLightbox(link.href, link.dataset.lightbox === 'video');
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user