Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c287eaa0 | ||
|
|
cec96cab1e | ||
|
|
a9188f919b | ||
|
|
0edb8c12aa | ||
|
|
15c9528f47 | ||
|
|
9ee5048a43 | ||
|
|
2d43600066 | ||
|
|
d6e6075469 | ||
|
|
289b1e8d37 | ||
|
|
c4823b7e64 | ||
|
|
f7a89b431d | ||
|
|
1bee7a7940 | ||
|
|
1bd3e17672 | ||
|
|
91ca486e64 | ||
|
|
2389bca42b | ||
|
|
520960d009 | ||
|
|
0cda791d44 | ||
|
|
a65488c304 | ||
|
|
4d9d0a894c | ||
|
|
fd1e78ba8c | ||
|
|
99e2cbc1f0 | ||
|
|
71f444b9aa | ||
|
|
a8de7cfa33 | ||
|
|
f7dcefeea6 | ||
|
|
757ebea2ba | ||
|
|
4d41513994 | ||
|
|
43441ee430 | ||
|
|
90fd4f86f8 | ||
|
|
77f6b5c5e2 | ||
|
|
3a084a9d79 | ||
|
|
85512ab48b | ||
|
|
3bf62c80d5 | ||
|
|
4cc07632f0 | ||
|
|
7b0017d1f4 | ||
|
|
1d2722b715 |
@@ -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
+1681
-127
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -1,10 +1,11 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "0.1.6"
|
||||
version = "1.0.5"
|
||||
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,5 +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"] }
|
||||
|
||||
+7
-3
@@ -1,14 +1,18 @@
|
||||
FROM rust:1-slim AS builder
|
||||
FROM rust:1-slim-bookworm AS builder
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||
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"]
|
||||
|
||||
+1234
-90
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,249 @@
|
||||
#[allow(dead_code)]
|
||||
#[path = "../models.rs"]
|
||||
mod models;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../uploads.rs"]
|
||||
mod uploads;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
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: &Path) -> Result<bool, std::io::Error> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(metadata) => Ok(metadata.is_file() && metadata.len() > 0),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn valid_local_video(path: &Path, media_id: i64) -> Result<bool, std::io::Error> {
|
||||
if !local_file_exists(path).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
match uploads::validate_video_file(path).await {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"Ignoring incomplete local output for media {media_id}: {} ({error})",
|
||||
path.display()
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_to_workspace(
|
||||
r2: &uploads::Storage,
|
||||
db_path: &str,
|
||||
workspace: &Path,
|
||||
filename: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let destination = workspace.join(filename);
|
||||
r2.download_to_path(db_path, &destination).await?;
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
async fn prepare_missing_objects(
|
||||
r2: &uploads::Storage,
|
||||
media_id: i64,
|
||||
original_db_path: &str,
|
||||
target_db_path: &str,
|
||||
target_thumbnail_db_path: &str,
|
||||
target_in_r2: bool,
|
||||
thumbnail_in_r2: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let workspace =
|
||||
std::env::temp_dir().join(format!("web-petting-normalize-{}", uuid::Uuid::new_v4()));
|
||||
tokio::fs::create_dir(&workspace).await?;
|
||||
|
||||
let result = async {
|
||||
let original_path = uploads::resolve_db_path(original_db_path);
|
||||
let local_target_path = uploads::resolve_db_path(target_db_path);
|
||||
let generated_video_path = workspace.join("video.web.mp4");
|
||||
let generated_thumbnail_path = workspace.join("video.web.thumb.jpg");
|
||||
let mut prepared_video: Option<PathBuf> = None;
|
||||
let mut prepared_thumbnail: Option<PathBuf> = None;
|
||||
|
||||
if !target_in_r2 {
|
||||
if valid_local_video(&local_target_path, media_id).await? {
|
||||
println!("Reusing valid compact video on PVC for media {media_id}");
|
||||
prepared_video = Some(local_target_path.clone());
|
||||
} else {
|
||||
let source = if original_db_path != target_db_path
|
||||
&& local_file_exists(&original_path).await?
|
||||
{
|
||||
original_path.clone()
|
||||
} else if original_db_path != target_db_path && r2.exists(original_db_path).await? {
|
||||
println!("Downloading source from R2 for media {media_id}");
|
||||
let extension = original_db_path.rsplit('.').next().unwrap_or("mov");
|
||||
download_to_workspace(
|
||||
r2,
|
||||
original_db_path,
|
||||
&workspace,
|
||||
&format!("source.{extension}"),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
return Err(format!(
|
||||
"source is absent from both PVC and R2: {}",
|
||||
original_path.display()
|
||||
)
|
||||
.into());
|
||||
};
|
||||
|
||||
println!("Converting media {media_id}: {original_db_path}");
|
||||
uploads::create_compact_video_files(
|
||||
&source,
|
||||
&generated_video_path,
|
||||
&generated_thumbnail_path,
|
||||
)
|
||||
.await?;
|
||||
prepared_video = Some(generated_video_path.clone());
|
||||
prepared_thumbnail = Some(generated_thumbnail_path.clone());
|
||||
}
|
||||
|
||||
let video_path = prepared_video
|
||||
.as_ref()
|
||||
.ok_or("compact video was not prepared")?;
|
||||
uploads::validate_video_file(video_path).await?;
|
||||
r2.upload_local_copy(target_db_path, video_path).await?;
|
||||
println!("Uploaded compact video: {target_db_path}");
|
||||
}
|
||||
|
||||
if !thumbnail_in_r2 {
|
||||
let video_for_preview = if let Some(video_path) = prepared_video.as_ref() {
|
||||
video_path.clone()
|
||||
} else if valid_local_video(&local_target_path, media_id).await? {
|
||||
local_target_path
|
||||
} else {
|
||||
println!("Downloading compact video from R2 for media {media_id}");
|
||||
let downloaded =
|
||||
download_to_workspace(r2, target_db_path, &workspace, "existing-video.web.mp4")
|
||||
.await?;
|
||||
uploads::validate_video_file(&downloaded).await?;
|
||||
downloaded
|
||||
};
|
||||
|
||||
let thumbnail_path = if let Some(thumbnail_path) = prepared_thumbnail.as_ref() {
|
||||
thumbnail_path.clone()
|
||||
} else {
|
||||
uploads::create_video_thumbnail_file(&video_for_preview, &generated_thumbnail_path)
|
||||
.await?;
|
||||
generated_thumbnail_path
|
||||
};
|
||||
if !local_file_exists(&thumbnail_path).await? {
|
||||
return Err(format!(
|
||||
"video preview was not created: {}",
|
||||
thumbnail_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
r2.upload_local_copy(target_thumbnail_db_path, &thumbnail_path)
|
||||
.await?;
|
||||
println!("Uploaded video preview: {target_thumbnail_db_path}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&workspace).await;
|
||||
result
|
||||
}
|
||||
|
||||
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 mut target_in_r2 = r2.exists(&target_db_path).await?;
|
||||
let mut thumbnail_in_r2 = r2.exists(&target_thumbnail_db_path).await?;
|
||||
|
||||
if !target_in_r2 || !thumbnail_in_r2 {
|
||||
if let Err(error) = prepare_missing_objects(
|
||||
&r2,
|
||||
media_id,
|
||||
&original_db_path,
|
||||
&target_db_path,
|
||||
&target_thumbnail_db_path,
|
||||
target_in_r2,
|
||||
thumbnail_in_r2,
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("Could not prepare media {media_id}: {error}");
|
||||
if media.status == "active" {
|
||||
failed += 1;
|
||||
} else {
|
||||
missing_archived += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
target_in_r2 = r2.exists(&target_db_path).await?;
|
||||
thumbnail_in_r2 = r2.exists(&target_thumbnail_db_path).await?;
|
||||
}
|
||||
|
||||
if !target_in_r2 || !thumbnail_in_r2 {
|
||||
eprintln!("Could not verify normalized R2 objects for media {media_id}");
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if original_db_path != target_db_path {
|
||||
// Both replacements are verified before old R2 keys are removed.
|
||||
// Original files on the PVC are 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);
|
||||
}
|
||||
}
|
||||
+233
-3
@@ -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,
|
||||
@@ -118,6 +121,8 @@ pub struct Translations {
|
||||
pub users_add_button: &'static str,
|
||||
pub users_error_passwords_mismatch: &'static str,
|
||||
pub users_error_login_taken: &'static str,
|
||||
pub users_telegram_chat_id: &'static str,
|
||||
pub users_telegram_enabled: &'static str,
|
||||
|
||||
// Settings
|
||||
pub settings_title: &'static str,
|
||||
@@ -126,11 +131,59 @@ pub struct Translations {
|
||||
pub settings_save: &'static str,
|
||||
pub settings_saved: &'static str,
|
||||
pub settings_empty: &'static str,
|
||||
pub settings_intro: &'static str,
|
||||
pub settings_section_general_help: &'static str,
|
||||
pub settings_section_notifications_help: &'static str,
|
||||
pub settings_section_captcha_help: &'static str,
|
||||
pub settings_section_oidc_help: &'static str,
|
||||
pub settings_secret_saved: &'static str,
|
||||
pub settings_secret_not_set: &'static str,
|
||||
pub settings_secret_clear: &'static str,
|
||||
pub settings_status_enabled: &'static str,
|
||||
pub settings_status_disabled: &'static str,
|
||||
pub settings_telegram_bot_token: &'static str,
|
||||
pub settings_telegram_chat_id: &'static str,
|
||||
pub settings_contact_info: &'static str,
|
||||
pub settings_pricing_info: &'static str,
|
||||
pub settings_timezone: &'static str,
|
||||
pub settings_site_domain: &'static str,
|
||||
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,
|
||||
|
||||
@@ -145,6 +198,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,
|
||||
@@ -234,6 +292,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,
|
||||
@@ -254,6 +313,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,
|
||||
@@ -266,6 +330,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,
|
||||
@@ -314,8 +390,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: "Логин",
|
||||
@@ -328,6 +407,8 @@ static RU: Translations = Translations {
|
||||
users_add_button: "Добавить",
|
||||
users_error_passwords_mismatch: "Пароли не совпадают.",
|
||||
users_error_login_taken: "Этот логин уже занят.",
|
||||
users_telegram_chat_id: "Telegram Chat ID",
|
||||
users_telegram_enabled: "Уведомления",
|
||||
|
||||
settings_title: "Настройки",
|
||||
settings_key: "Параметр",
|
||||
@@ -335,11 +416,59 @@ static RU: Translations = Translations {
|
||||
settings_save: "Сохранить",
|
||||
settings_saved: "Сохранено!",
|
||||
settings_empty: "Настройки не заданы.",
|
||||
settings_intro: "Параметры сайта, интеграций, хранения медиа и способов входа.",
|
||||
settings_section_general_help: "Публичные данные сайта и параметры, используемые при формировании ссылок и дат.",
|
||||
settings_section_notifications_help: "Telegram для администратора и браузерные push-уведомления для клиентов.",
|
||||
settings_section_captcha_help: "Ключи Cloudflare Turnstile для защиты публичных форм от автоматических отправок.",
|
||||
settings_section_oidc_help: "Настройте доступ администраторов по паролю и через внешний OIDC-провайдер.",
|
||||
settings_secret_saved: "Секрет сохранён. Оставьте поле пустым, чтобы не менять его.",
|
||||
settings_secret_not_set: "Секрет пока не задан.",
|
||||
settings_secret_clear: "Удалить сохранённый секрет",
|
||||
settings_status_enabled: "Включено",
|
||||
settings_status_disabled: "Выключено",
|
||||
settings_telegram_bot_token: "Токен Telegram бота",
|
||||
settings_telegram_chat_id: "Chat ID для уведомлений",
|
||||
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
||||
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
||||
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
||||
settings_site_domain: "Домен сайта (например https://example.com)",
|
||||
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: "Стоимость",
|
||||
|
||||
@@ -359,6 +488,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: "Предстоящие визиты",
|
||||
@@ -370,10 +504,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: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
||||
@@ -392,7 +543,7 @@ static RU: Translations = Translations {
|
||||
schedule_new_title: "Запланировать визиты",
|
||||
schedule_client: "Клиент",
|
||||
schedule_admin: "Исполнитель",
|
||||
schedule_default_time: "Время по умолчанию",
|
||||
schedule_default_time: "Время",
|
||||
schedule_time_start: "С",
|
||||
schedule_time_end: "До",
|
||||
schedule_pick_dates: "Добавить дату",
|
||||
@@ -409,6 +560,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: "Редактировать визит",
|
||||
@@ -419,7 +571,7 @@ static RU: Translations = Translations {
|
||||
schedule_delete_confirm: "Точно удалить этот визит?",
|
||||
|
||||
landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!",
|
||||
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома",
|
||||
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома. Город Хабаровск",
|
||||
landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.",
|
||||
landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
|
||||
landing_hero_cta: "Оставить заявку",
|
||||
@@ -513,8 +665,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",
|
||||
@@ -527,6 +682,8 @@ static EN: Translations = Translations {
|
||||
users_add_button: "Add",
|
||||
users_error_passwords_mismatch: "Passwords do not match.",
|
||||
users_error_login_taken: "This login is already taken.",
|
||||
users_telegram_chat_id: "Telegram Chat ID",
|
||||
users_telegram_enabled: "Notifications",
|
||||
|
||||
settings_title: "Settings",
|
||||
settings_key: "Parameter",
|
||||
@@ -534,11 +691,59 @@ static EN: Translations = Translations {
|
||||
settings_save: "Save",
|
||||
settings_saved: "Saved!",
|
||||
settings_empty: "No settings configured.",
|
||||
settings_intro: "Site, integration, media storage, and sign-in settings.",
|
||||
settings_section_general_help: "Public site details and values used to build links and display dates.",
|
||||
settings_section_notifications_help: "Telegram alerts for administrators and browser push notifications for clients.",
|
||||
settings_section_captcha_help: "Cloudflare Turnstile keys used to protect public forms from automated submissions.",
|
||||
settings_section_oidc_help: "Configure administrator access with passwords and an external OIDC provider.",
|
||||
settings_secret_saved: "A secret is stored. Leave this field blank to keep it unchanged.",
|
||||
settings_secret_not_set: "No secret is currently stored.",
|
||||
settings_secret_clear: "Remove the stored secret",
|
||||
settings_status_enabled: "Enabled",
|
||||
settings_status_disabled: "Disabled",
|
||||
settings_telegram_bot_token: "Telegram Bot Token",
|
||||
settings_telegram_chat_id: "Notification Chat ID",
|
||||
settings_contact_info: "Contact info (shown on landing page)",
|
||||
settings_pricing_info: "Pricing block (shown on landing page)",
|
||||
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
||||
settings_site_domain: "Site domain (e.g. https://example.com)",
|
||||
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",
|
||||
|
||||
@@ -558,6 +763,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",
|
||||
@@ -569,10 +779,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.",
|
||||
@@ -591,7 +818,7 @@ static EN: Translations = Translations {
|
||||
schedule_new_title: "Plan Visits",
|
||||
schedule_client: "Client",
|
||||
schedule_admin: "Assigned to",
|
||||
schedule_default_time: "Default Time",
|
||||
schedule_default_time: "Time",
|
||||
schedule_time_start: "From",
|
||||
schedule_time_end: "To",
|
||||
schedule_pick_dates: "Add date",
|
||||
@@ -608,6 +835,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",
|
||||
@@ -694,6 +922,7 @@ impl Translations {
|
||||
"scheduled" => self.visit_status_scheduled,
|
||||
"completed" => self.visit_status_completed,
|
||||
"cancelled" => self.visit_status_cancelled,
|
||||
"deleted" => self.visit_status_deleted,
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
@@ -702,6 +931,7 @@ impl Translations {
|
||||
match status {
|
||||
"active" => self.client_status_active,
|
||||
"archived" => self.client_status_archived,
|
||||
"deleted" => self.client_status_deleted,
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
+74
-13
@@ -4,16 +4,19 @@ mod migrations;
|
||||
pub mod models;
|
||||
mod public;
|
||||
mod telegram;
|
||||
mod turnstile;
|
||||
mod tz;
|
||||
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};
|
||||
@@ -36,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()
|
||||
}
|
||||
@@ -48,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)
|
||||
@@ -95,7 +131,32 @@ impl Project for PettingProject {
|
||||
}
|
||||
}
|
||||
|
||||
#[cot::main]
|
||||
fn main() -> impl Project {
|
||||
PettingProject
|
||||
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();
|
||||
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
-9
@@ -1,17 +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_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_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()];
|
||||
}
|
||||
+23
-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,
|
||||
@@ -179,6 +181,8 @@ pub struct User {
|
||||
pub login: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
pub telegram_chat_id: Option<String>,
|
||||
pub telegram_notifications: Option<bool>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
@@ -213,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,
|
||||
}
|
||||
|
||||
+826
-53
File diff suppressed because it is too large
Load Diff
+28
-14
@@ -1,8 +1,8 @@
|
||||
use cot::db::{Database, query};
|
||||
|
||||
use crate::models::Setting;
|
||||
use crate::models::{Setting, User};
|
||||
|
||||
/// Send a Telegram message using bot settings from DB.
|
||||
/// Send a Telegram notification to all admins with notifications enabled.
|
||||
/// Silently ignores errors (missing config, network issues) — notifications are best-effort.
|
||||
pub async fn notify_new_lead(
|
||||
db: &Database,
|
||||
@@ -14,10 +14,6 @@ pub async fn notify_new_lead(
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return,
|
||||
};
|
||||
let chat_id = match get_setting(db, "telegram_chat_id").await {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let mut text = format!("📋 Новая заявка!\n\nИмя: {name}");
|
||||
if let Some(phone) = phone.filter(|s| !s.is_empty()) {
|
||||
@@ -27,15 +23,33 @@ pub async fn notify_new_lead(
|
||||
text.push_str(&format!("\nКомментарий: {comment}"));
|
||||
}
|
||||
|
||||
let active = "active".to_string();
|
||||
let users = match query!(User, $status == active).all(db).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("https://api.telegram.org/bot{token}/sendMessage");
|
||||
let _ = reqwest::Client::new()
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
for user in &users {
|
||||
if user.telegram_notifications != Some(true) {
|
||||
continue;
|
||||
}
|
||||
let chat_id = match &user.telegram_chat_id {
|
||||
Some(id) if !id.is_empty() => id,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_setting(db: &Database, key_name: &str) -> Option<String> {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use cot::db::{Database, query};
|
||||
|
||||
use crate::models::Setting;
|
||||
|
||||
/// Read `turnstile_site_key` from Settings. Returns empty string if not configured.
|
||||
pub async fn get_site_key(db: &Database) -> cot::Result<String> {
|
||||
let key = "turnstile_site_key".to_string();
|
||||
Ok(query!(Setting, $key == key)
|
||||
.get(db)
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Verify a Turnstile token against Cloudflare.
|
||||
/// Returns `true` if verification succeeds, or if no secret key is configured (passthrough).
|
||||
pub async fn verify(db: &Database, token: Option<&str>) -> cot::Result<bool> {
|
||||
let secret_key_name = "turnstile_secret_key".to_string();
|
||||
let secret_key = query!(Setting, $key == secret_key_name)
|
||||
.get(db)
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let Some(secret) = secret_key else {
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
let token = token.unwrap_or("");
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
|
||||
.json(&serde_json::json!({
|
||||
"secret": secret,
|
||||
"response": token
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
Ok(match resp {
|
||||
Ok(r) => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.map(|v| v["success"].as_bool() == Some(true))
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
})
|
||||
}
|
||||
+1063
File diff suppressed because it is too large
Load Diff
+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())
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
:root {
|
||||
--admin-accent: #7567e8;
|
||||
--admin-accent-dark: #6255cf;
|
||||
--admin-accent-soft: #efedff;
|
||||
--admin-bg: #f6f5f9;
|
||||
--admin-surface: #fff;
|
||||
--admin-surface-soft: #fbfaff;
|
||||
--admin-border: #e7e5ef;
|
||||
--admin-border-soft: #eeecf3;
|
||||
--admin-text: #302c49;
|
||||
--admin-muted: #817c94;
|
||||
--admin-danger: #b54e5b;
|
||||
--admin-success: #177349;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { background: var(--admin-bg); }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--admin-bg);
|
||||
color: var(--admin-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
a { color: var(--admin-accent-dark); }
|
||||
|
||||
/* Application shell */
|
||||
.admin-shell { padding-bottom: 4.35rem; }
|
||||
|
||||
.top-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 3.5rem;
|
||||
padding: .55rem 1rem;
|
||||
background: rgba(255,255,255,.94);
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
box-shadow: 0 1px 8px rgba(42,34,80,.035);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.top-header .brand {
|
||||
flex: 0 0 auto;
|
||||
color: var(--admin-text);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
color: #aaa5b6;
|
||||
font-size: .66rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.top-header-right {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: .7rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.top-header-right a { color: #777184; text-decoration: none; }
|
||||
.top-header-right a:hover { color: var(--admin-accent-dark); }
|
||||
.top-header-right .admin-name { color: #aaa5b6; }
|
||||
|
||||
.desktop-nav { display: none; }
|
||||
|
||||
.bottom-tabs {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
height: 3.65rem;
|
||||
overflow-x: auto;
|
||||
background: rgba(255,255,255,.96);
|
||||
border-top: 1px solid var(--admin-border);
|
||||
box-shadow: 0 -4px 18px rgba(42,34,80,.06);
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.bottom-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.bottom-tabs a {
|
||||
display: flex;
|
||||
flex: 1 0 3.2rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 3.2rem;
|
||||
padding: 0 .42rem;
|
||||
color: #9993a8;
|
||||
font-size: .59rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottom-tabs a .tab-icon { font-size: 1.2rem; line-height: 1; }
|
||||
.bottom-tabs a .tab-label { display: block; margin-top: .12rem; }
|
||||
.bottom-tabs a.is-active { color: var(--admin-accent); }
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 2rem;
|
||||
}
|
||||
|
||||
/* Typography and page chrome */
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: .8rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-head h1 {
|
||||
margin: 0;
|
||||
color: var(--admin-text);
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
|
||||
.page-head p,
|
||||
.page-description {
|
||||
margin: .25rem 0 0;
|
||||
color: var(--admin-muted);
|
||||
font-size: .86rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.page-head-actions,
|
||||
.toolbar,
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 1.55rem 0 .75rem;
|
||||
color: #514b69;
|
||||
font-size: .82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-inline-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .75rem; }
|
||||
.section-inline-head .label { margin: 0; }
|
||||
.form-card hr { height: 1px; margin: 1rem 0; background: var(--admin-border-soft); border: 0; }
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem 1rem;
|
||||
color: var(--admin-muted);
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,.65);
|
||||
border: 1px dashed #dcd8e8;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button {
|
||||
border-radius: 8px;
|
||||
box-shadow: none !important;
|
||||
font-weight: 700;
|
||||
transition: transform .12s, border-color .15s, background .15s;
|
||||
}
|
||||
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button:active { transform: none; }
|
||||
.button.is-primary { background: var(--admin-accent); border-color: var(--admin-accent); }
|
||||
.button.is-primary:not(.is-outlined):not(.is-light),
|
||||
.button.is-link:not(.is-outlined):not(.is-light),
|
||||
.button.is-info:not(.is-outlined):not(.is-light),
|
||||
.button.is-success:not(.is-outlined):not(.is-light),
|
||||
.button.is-danger:not(.is-outlined):not(.is-light) { color: #fff !important; }
|
||||
.button.is-primary:hover { color: #fff !important; background: var(--admin-accent-dark); border-color: var(--admin-accent-dark); }
|
||||
.button.is-primary.is-outlined { color: var(--admin-accent-dark) !important; background: transparent; border-color: #a49bea; }
|
||||
.button.is-primary.is-outlined:hover { color: #fff !important; background: var(--admin-accent); border-color: var(--admin-accent); }
|
||||
.button.is-light { background: #f3f1f8; color: #5f5971; }
|
||||
.button[disabled] { color: #8d8798 !important; background: #eceaf0 !important; border-color: #e0dde7 !important; opacity: 1; transform: none; }
|
||||
.button.is-small,
|
||||
.btn-sm { height: auto !important; padding: .32rem .65rem !important; font-size: .75rem !important; }
|
||||
|
||||
/* Reusable cards and sections */
|
||||
.admin-section,
|
||||
.form-card {
|
||||
overflow: hidden;
|
||||
background: var(--admin-surface);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(42,34,80,.035);
|
||||
}
|
||||
|
||||
.form-card { padding: 1.2rem; }
|
||||
|
||||
.admin-section-head {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0,1fr);
|
||||
gap: .8rem;
|
||||
padding: 1rem 1.15rem;
|
||||
background: linear-gradient(180deg,#fff,var(--admin-surface-soft));
|
||||
border-bottom: 1px solid #eceaf2;
|
||||
}
|
||||
|
||||
.admin-section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: var(--admin-accent-soft);
|
||||
border-radius: 11px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.admin-section-head h2 {
|
||||
margin: 0;
|
||||
color: var(--admin-text);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.admin-section-head p {
|
||||
max-width: 680px;
|
||||
margin: .2rem 0 0;
|
||||
color: var(--admin-muted);
|
||||
font-size: .82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.admin-section-body { padding: 1.15rem; }
|
||||
|
||||
.admin-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: .12rem .48rem;
|
||||
color: #777184;
|
||||
background: #eeedf2;
|
||||
border-radius: 99px;
|
||||
font-size: .67rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-pill.is-enabled { color: var(--admin-success); background: #dcf7e8; }
|
||||
|
||||
.admin-grid,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2,minmax(0,1fr));
|
||||
gap: 1rem 1.1rem;
|
||||
}
|
||||
.form-grid > .field { margin: 0; }
|
||||
.page-head .button.is-primary { min-height: 2.25rem; padding: .42rem .8rem !important; }
|
||||
|
||||
.admin-field { min-width: 0; margin: 0 !important; }
|
||||
.admin-field-wide { grid-column: 1 / -1; }
|
||||
|
||||
.admin-subsection {
|
||||
margin-top: 1.1rem;
|
||||
padding-top: 1.1rem;
|
||||
border-top: 1px solid var(--admin-border-soft);
|
||||
}
|
||||
|
||||
.admin-subsection h2,
|
||||
.admin-subsection h3,
|
||||
.form-card > h2 {
|
||||
margin: 0 0 .75rem;
|
||||
color: #514b69;
|
||||
font-size: .82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
display: block;
|
||||
margin-bottom: .65rem;
|
||||
padding: .9rem 1rem;
|
||||
color: inherit;
|
||||
background: var(--admin-surface);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 6px rgba(42,34,80,.025);
|
||||
transition: border-color .15s, box-shadow .15s, transform .15s;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
border-color: #dcd8eb;
|
||||
box-shadow: 0 5px 18px rgba(42,34,80,.055);
|
||||
}
|
||||
|
||||
.item-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: .75rem;
|
||||
margin-bottom: .35rem;
|
||||
}
|
||||
|
||||
.item-card-header .name {
|
||||
min-width: 0;
|
||||
color: var(--admin-text);
|
||||
font-size: .95rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.item-card-meta {
|
||||
display: flex;
|
||||
gap: .25rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
color: var(--admin-muted);
|
||||
font-size: .79rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.item-card-meta a { color: inherit; text-decoration: none; }
|
||||
|
||||
.item-card-actions {
|
||||
display: flex;
|
||||
gap: .4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: .65rem;
|
||||
padding-top: .6rem;
|
||||
border-top: 1px solid var(--admin-border-soft);
|
||||
}
|
||||
|
||||
.item-card-actions form { margin: 0; }
|
||||
.color-dot { display: inline-block; width: 11px; height: 11px; margin-right: 6px; border-radius: 50%; vertical-align: middle; }
|
||||
.card-link { color: inherit; text-decoration: none; }
|
||||
.card-note { margin-top: .35rem; color: var(--admin-muted); font-size: .78rem; line-height: 1.45; }
|
||||
.feedback-card { border-left: 3px solid var(--admin-accent); text-decoration: none; }
|
||||
.feedback-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .3rem; }
|
||||
.feedback-date { color: #9993a8; font-size: .75rem; white-space: nowrap; }
|
||||
.feedback-text { color: #4a4570; font-size: .82rem; line-height: 1.5; }
|
||||
.muted-detail { color: var(--admin-muted); font-weight: 450; }
|
||||
|
||||
/* Statuses */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
padding: .16rem .58rem;
|
||||
border-radius: 99px;
|
||||
font-size: .69rem;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-new,
|
||||
.badge-visit-scheduled { color: #1e40af; background: #dbeafe; }
|
||||
.badge-in_progress { color: #92400e; background: #fef3c7; }
|
||||
.badge-converted,
|
||||
.badge-active,
|
||||
.badge-visit-completed { color: #065f46; background: #d1fae5; }
|
||||
.badge-rejected { color: #991b1b; background: #fee2e2; }
|
||||
.badge-archived,
|
||||
.badge-visit-cancelled { color: #554f61; background: #ebe9ef; }
|
||||
|
||||
/* Forms */
|
||||
.field:not(:last-child) { margin-bottom: 1rem; }
|
||||
.field .label,
|
||||
.admin-field .label {
|
||||
margin-bottom: .38rem;
|
||||
color: #514c66 !important;
|
||||
font-size: .76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
.input,
|
||||
.textarea,
|
||||
.select select {
|
||||
color: var(--admin-text) !important;
|
||||
background-color: #fff !important;
|
||||
border-color: #dcd9e5 !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: none !important;
|
||||
font-size: .86rem;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select select { min-height: 2.45rem; }
|
||||
.textarea { min-height: 76px; resize: vertical; }
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
.input:focus,
|
||||
.textarea:focus,
|
||||
.select select:focus {
|
||||
border-color: #7c6cff !important;
|
||||
box-shadow: 0 0 0 2px rgba(124,108,255,.12) !important;
|
||||
}
|
||||
|
||||
input[readonly],
|
||||
.input[readonly] { color: #716b81 !important; background: #f8f7fa !important; }
|
||||
|
||||
input[type="file"].input { padding: .36rem .55rem; }
|
||||
input[type="color"] { width: 3rem; height: 2.35rem; padding: 2px; cursor: pointer; }
|
||||
input[type="checkbox"] { accent-color: var(--admin-accent); }
|
||||
|
||||
.field-help {
|
||||
margin-top: .35rem;
|
||||
color: #8b869b;
|
||||
font-size: .72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.secret-control { position: relative; }
|
||||
.secret-control::after { position: absolute; top: 50%; right: .75rem; content: "🔒"; transform: translateY(-50%); opacity: .48; font-size: .78rem; pointer-events: none; }
|
||||
.secret-control .input { padding-right: 2.25rem; letter-spacing: .05em; }
|
||||
.admin-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.admin-toggle-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .7rem; margin-bottom: 1rem; }
|
||||
.admin-toggle { position: relative; display: flex; align-items: flex-start; gap: .7rem; padding: .75rem .85rem; color: inherit !important; background: var(--admin-surface-soft); border: 1px solid #e8e5f2; border-radius: 10px; cursor: pointer; transition: border-color .15s,background .15s; }
|
||||
.admin-toggle:hover { background: #f8f6ff; border-color: #dcd7ef; }
|
||||
.admin-toggle > input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.admin-toggle-track { flex: 0 0 auto; width: 38px; height: 22px; padding: 2px; background: #ccc8d6; border-radius: 99px; transition: .18s; }
|
||||
.admin-toggle-track span { display: block; width: 18px; height: 18px; background: #fff; border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,.2); transition: .18s; }
|
||||
.admin-toggle > input:checked + .admin-toggle-track { background: var(--admin-accent); }
|
||||
.admin-toggle > input:checked + .admin-toggle-track span { transform: translateX(16px); }
|
||||
.admin-toggle > input:focus-visible + .admin-toggle-track { outline: 2px solid #7c6cff; outline-offset: 2px; }
|
||||
.admin-toggle strong { display: block; color: #3e3956; font-size: .82rem; line-height: 1.35; }
|
||||
.admin-toggle small { display: block; margin-top: .15rem; color: #8a849c; font-size: .7rem; line-height: 1.35; }
|
||||
.admin-toggle-compact { align-items: center; padding: .55rem .65rem; }
|
||||
|
||||
.admin-check { position: relative; display: inline-flex; align-items: center; gap: .45rem; color: #716b81 !important; font-size: .72rem; line-height: 1.35; cursor: pointer; }
|
||||
.admin-check > input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.admin-check-box { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 18px; height: 18px; color: transparent; background: #fff; border: 1.5px solid #cfcad9; border-radius: 5px; font-size: .68rem; font-weight: 900; transition: .15s; }
|
||||
.admin-check > input:checked + .admin-check-box { color: #fff; background: var(--admin-accent); border-color: var(--admin-accent); }
|
||||
.admin-check > input:focus-visible + .admin-check-box { outline: 2px solid #7c6cff; outline-offset: 2px; }
|
||||
.admin-check-danger { color: #a04e58 !important; }
|
||||
.admin-check-danger > input:checked + .admin-check-box { background: var(--admin-danger); border-color: var(--admin-danger); }
|
||||
|
||||
.admin-note { margin: .9rem 0 1rem; padding: .75rem .9rem; border: 1px solid; border-radius: 9px; font-size: .76rem; line-height: 1.5; }
|
||||
.admin-note p + p { margin-top: .35rem; }
|
||||
.admin-note-info { color: #42627f; background: #f1f7ff; border-color: #d7e8fa; }
|
||||
.admin-note-warning { color: #785f28; background: #fff8e8; border-color: #f1dfb4; }
|
||||
.admin-note code { display: inline-block; margin: .2rem 0 0; padding: .12rem .35rem; color: inherit; background: rgba(255,255,255,.7); border-radius: 4px; user-select: all; }
|
||||
|
||||
.admin-details { margin-top: 1rem; overflow: hidden; background: #fcfbff; border: 1px solid #e8e5f2; border-radius: 9px; }
|
||||
.admin-details summary { display: flex; align-items: center; justify-content: space-between; padding: .7rem .85rem; color: #514b69; font-size: .78rem; font-weight: 700; cursor: pointer; }
|
||||
.admin-details summary span { min-width: 24px; padding: .05rem .4rem; color: #6257bb; text-align: center; background: #ece9ff; border-radius: 99px; }
|
||||
.admin-details-body { padding: 0 .85rem .85rem; }
|
||||
|
||||
.sticky-actions {
|
||||
position: sticky;
|
||||
bottom: 4.3rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: .65rem;
|
||||
background: rgba(255,255,255,.92);
|
||||
border: 1px solid rgba(222,218,238,.9);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 28px rgba(46,38,86,.12);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.sticky-actions .button { min-width: 150px; }
|
||||
|
||||
.danger-zone { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #f1dfe2; }
|
||||
.inline-control { display: flex; align-items: center; gap: .5rem; }
|
||||
.inline-control > .input,
|
||||
.inline-control > .select { flex: 1; min-width: 0; }
|
||||
.color-control { display: flex; align-items: center; gap: .6rem; }
|
||||
.portal-panel { margin-top: 1.1rem; padding-top: 1.1rem; border-top: 1px solid var(--admin-border-soft); }
|
||||
.portal-qr { margin-top: .8rem; text-align: center; }
|
||||
.portal-qr canvas { max-width: 180px; padding: .4rem; background: #fff; border: 1px solid var(--admin-border); border-radius: 10px; }
|
||||
.inline-admin-form { margin-top: .7rem; padding-top: .7rem; border-top: 1px solid var(--admin-border-soft); }
|
||||
.inline-admin-grid { display: grid; grid-template-columns: minmax(180px,1fr) minmax(180px,.8fr) auto; align-items: end; gap: .75rem; }
|
||||
.inline-admin-grid .field { margin: 0; }
|
||||
.testimonial-content { display: flex; align-items: flex-start; gap: .75rem; min-width: 0; }
|
||||
.testimonial-avatar { flex: 0 0 auto; width: 48px; height: 48px; border-radius: 12px; object-fit: cover; }
|
||||
.testimonial-text { color: #49435d; font-size: .88rem; line-height: 1.5; }
|
||||
.testimonial-note { margin-top: .2rem; color: var(--admin-muted); font-size: .76rem; }
|
||||
|
||||
/* Messages and tables */
|
||||
.notification { color: var(--admin-text) !important; border-radius: 10px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.table-wrap .table { margin: 0; background: transparent; font-size: .75rem; }
|
||||
.table th { color: #696278 !important; font-size: .7rem; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.is-nowrap { white-space: nowrap; }
|
||||
.seo-preview { min-height: 2rem; margin-top: .5rem; padding: .5rem .65rem; background: #fafafa; border: 1px solid #ebe9ef; border-radius: 7px; font-size: .78rem; line-height: 2; }
|
||||
|
||||
.pagination-row,
|
||||
.media-pagination { display: flex; justify-content: center; gap: .35rem; flex-wrap: wrap; margin-top: 1.25rem; }
|
||||
|
||||
/* Media */
|
||||
.filter-bar { display: flex; align-items: center; gap: .6rem; margin-bottom: 1rem; padding: .65rem .75rem; background: #fff; border: 1px solid var(--admin-border); border-radius: 10px; }
|
||||
.media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(200px,1fr)); gap: .8rem; }
|
||||
.media-card { overflow: hidden; background: #fff; border: 1px solid var(--admin-border); border-radius: 12px; box-shadow: 0 1px 6px rgba(42,34,80,.025); transition: transform .15s, box-shadow .15s; }
|
||||
.media-card:hover { transform: translateY(-2px); box-shadow: 0 7px 20px rgba(42,34,80,.08); }
|
||||
.media-card img { display: block; width: 100%; height: 160px; object-fit: cover; }
|
||||
.media-card .photo-thumb { width: 100%; height: 160px; }
|
||||
.media-card .video-thumb { position: relative; width: 100%; height: 160px; background: #17151e; }
|
||||
.media-card .video-play,
|
||||
.visit-media-item .video-play { position: absolute; top: 50%; left: 50%; color: #fff; line-height: 1; text-shadow: 0 1px 5px #000; transform: translate(-50%,-50%); pointer-events: none; }
|
||||
.media-card .video-play { font-size: 2.5rem; }
|
||||
.media-info { padding: .7rem .8rem; }
|
||||
.media-meta { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: var(--admin-text); font-size: .82rem; }
|
||||
.media-caption { margin-top: .25rem; color: #746e82; font-size: .78rem; }
|
||||
|
||||
.visit-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(105px,1fr)); gap: .55rem; margin-bottom: 1rem; }
|
||||
.visit-media-item { overflow: hidden; background: #faf9fc; border: 1px solid var(--admin-border); border-radius: 9px; }
|
||||
.visit-media-item img,
|
||||
.visit-media-item .photo-thumb,
|
||||
.visit-media-item .video-thumb-sm { display: block; width: 100%; height: 82px; object-fit: cover; }
|
||||
.visit-media-item .video-thumb-sm { position: relative; background: #17151e; }
|
||||
.visit-media-item .video-play { font-size: 1.6rem; }
|
||||
.visit-media-item .media-cap { padding: .25rem .4rem; overflow: hidden; color: var(--admin-muted); font-size: .69rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.visit-media-delete { padding: .25rem .4rem .4rem; }
|
||||
.visit-media-delete .button { width: 100%; min-height: 1.65rem; font-size: .66rem; }
|
||||
|
||||
/* Upload queue */
|
||||
.upload-context { margin-bottom: 1rem; padding: .7rem .8rem; color: #686276; background: var(--admin-surface-soft); border: 1px solid var(--admin-border-soft); border-radius: 9px; font-size: .82rem; }
|
||||
.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); align-items: center; gap: .55rem; padding: .45rem .55rem; background: #f7f6ff; border-radius: 8px; }
|
||||
.upload-queue-icon { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; background: #ebe8ff; border-radius: 7px; font-size: 1.05rem; }
|
||||
.upload-queue-name { overflow: hidden; color: #494467; font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.upload-queue-state { color: #8a84a5; font-size: .68rem; }
|
||||
.upload-queue-track { height: 3px; margin-top: .25rem; overflow: hidden; background: #dedbea; border-radius: 99px; }
|
||||
.upload-queue-bar { width: 0; height: 100%; background: var(--admin-accent); transition: width .2s; }
|
||||
.upload-progress { display: none; margin-bottom: 1rem; }
|
||||
.upload-progress-head { display: flex; justify-content: space-between; margin-bottom: .3rem; color: #5f596d; font-size: .78rem; }
|
||||
.upload-progress-track { height: 7px; overflow: hidden; background: #e8e6ed; border-radius: 99px; }
|
||||
.upload-progress-bar { width: 0; height: 100%; background: linear-gradient(90deg,#6c63ff,#b06cff); border-radius: 99px; 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%); } }
|
||||
|
||||
/* Schedule */
|
||||
#calendar-wrap { padding: .75rem; overflow: hidden; }
|
||||
.fc { color: var(--admin-text); font-size: .82rem; }
|
||||
.fc .fc-daygrid-day-number,
|
||||
.fc .fc-col-header-cell-cushion,
|
||||
.fc .fc-list-day-text,
|
||||
.fc .fc-list-day-side-text,
|
||||
.fc .fc-toolbar-title,
|
||||
.fc th,
|
||||
.fc td { color: var(--admin-text) !important; }
|
||||
.fc .fc-toolbar { gap: .55rem; flex-wrap: nowrap; }
|
||||
.fc .fc-toolbar-chunk { min-width: 0; }
|
||||
.fc .fc-toolbar-chunk:first-child,
|
||||
.fc .fc-toolbar-chunk:last-child { display: flex; align-items: center; }
|
||||
.fc .fc-button-group { display: flex; flex-wrap: nowrap; gap: .35rem; }
|
||||
.fc .fc-button-group > .fc-button { margin-left: 0 !important; border-radius: 7px !important; }
|
||||
.fc .fc-toolbar-title { font-size: 1.05rem !important; white-space: nowrap; }
|
||||
.fc .fc-button { padding: .28rem .52rem !important; color: #fff !important; background: var(--admin-accent) !important; border-color: var(--admin-accent) !important; border-radius: 7px !important; font-size: .76rem !important; }
|
||||
.fc-event { padding: 2px 5px; border: none !important; border-radius: 5px; cursor: pointer; }
|
||||
.fc-event.ev-completed .fc-event-title,
|
||||
.fc-event.ev-completed .fc-list-event-title,
|
||||
.fc-event.ev-cancelled .fc-event-title,
|
||||
.fc-event.ev-cancelled .fc-list-event-title { text-decoration: line-through; opacity: .75; }
|
||||
.fc .fc-day-today { background: #f0eeff !important; }
|
||||
.fc .fc-day.day-weekend { background: #fdf9f6; }
|
||||
.fc .fc-day-today.day-weekend { background: #f0eeff !important; }
|
||||
|
||||
.time-row { display: flex; align-items: center; gap: .5rem; }
|
||||
.time-block { display: flex; flex: 1; flex-direction: column; }
|
||||
.time-lbl { margin-bottom: .2rem; color: var(--admin-muted); font-size: .72rem; }
|
||||
.time-sep { padding-top: 1.2rem; color: #aaa5b6; font-size: 1.1rem; }
|
||||
.cal-toolbar { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .55rem; }
|
||||
.sched-cal { overflow: hidden; background: #faf9fc; border: 1px solid var(--admin-border); border-radius: 11px; }
|
||||
.cal-nav { display: flex; align-items: center; justify-content: space-between; padding: .65rem .75rem; background: #fff; border-bottom: 1px solid var(--admin-border-soft); }
|
||||
.cal-nav button { padding: .2rem .5rem; color: var(--admin-accent); background: none; border: 0; border-radius: 6px; font-size: 1.05rem; cursor: pointer; }
|
||||
.cal-nav button:hover { background: #f0eeff; }
|
||||
.cal-nav span { color: var(--admin-text); font-size: .95rem; font-weight: 750; }
|
||||
.cal-grid { display: grid; grid-template-columns: repeat(7,1fr); }
|
||||
.cal-wday { padding: .42rem 0; color: #aaa5b6; background: #faf9fc; font-size: .69rem; font-weight: 750; text-align: center; }
|
||||
.cal-wday.is-weekend { color: #d98a91; }
|
||||
.cal-day { position: relative; padding: .55rem .2rem; color: var(--admin-text); border-radius: 0; font-size: .86rem; text-align: center; cursor: pointer; transition: background .1s; user-select: none; }
|
||||
.cal-day:hover { background: #f0eeff; }
|
||||
.cal-day.is-empty { cursor: default; }
|
||||
.cal-day.is-empty:hover { background: none; }
|
||||
.cal-day.is-today { color: var(--admin-accent); font-weight: 750; }
|
||||
.cal-day.is-selected { color: #fff !important; background: var(--admin-accent) !important; }
|
||||
.cal-day.is-range-start { color: #fff !important; background: #a89cff !important; }
|
||||
.cal-day.is-past { color: #ccc8d3; }
|
||||
.day-row { display: flex; align-items: center; gap: .4rem; padding: .5rem 0; border-bottom: 1px solid #f2f0f5; }
|
||||
.day-row:last-child { border-bottom: 0; }
|
||||
.day-date { flex: 1; min-width: 0; overflow: hidden; font-size: .82rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.day-times { display: flex; flex-shrink: 0; align-items: center; gap: .25rem; }
|
||||
.day-times .time-sep { padding: 0; font-size: .78rem; }
|
||||
.day-rm { flex-shrink: 0; padding: .15rem .25rem; color: #bbb6c5; background: none; border: 0; font-size: 1rem; line-height: 1; cursor: pointer; }
|
||||
.day-rm:hover { color: var(--admin-danger); }
|
||||
.time-badge-wrap { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 3.6rem; padding: .15rem .5rem; color: #5b52d6; background: #ede9ff; border: 1.5px solid #c4beff; border-radius: 20px; font-size: .76rem; font-weight: 750; white-space: nowrap; cursor: pointer; }
|
||||
.time-badge-wrap:focus-within { background: #f0eeff; border-color: var(--admin-accent); }
|
||||
.time-badge-label { z-index: 1; pointer-events: none; }
|
||||
.time-badge-input { position: absolute; inset: 0; width: 100%; height: 100%; padding: 0; margin: 0; opacity: 0; background: transparent; border: 0; font-size: 16px; cursor: pointer; }
|
||||
|
||||
.status-picker { display: flex; gap: .5rem; }
|
||||
.status-btn { display: flex; flex: 1; flex-direction: column; align-items: center; gap: .3rem; padding: .6rem .4rem; color: #777184; background: #f5f4f7; border: 2px solid transparent; border-radius: 10px; font-size: .79rem; font-weight: 650; line-height: 1.2; cursor: pointer; transition: all .15s; }
|
||||
.status-btn-icon { font-size: 1.3rem; line-height: 1; }
|
||||
.status-btn:hover { filter: brightness(.97); }
|
||||
.status-btn-scheduled.is-active { color: #1e40af; background: #dbeafe; border-color: #3b82f6; }
|
||||
.status-btn-completed.is-active { color: #15803d; background: #d1fae5; border-color: #22c55e; }
|
||||
.status-btn-cancelled.is-active { color: #b91c1c; background: #fee2e2; border-color: #ef4444; }
|
||||
|
||||
/* Dialogs */
|
||||
.visit-modal-bg,
|
||||
.upload-modal-bg { position: fixed; inset: 0; z-index: 100; display: none; align-items: center; justify-content: center; padding: 1rem; background: rgba(31,27,48,.38); backdrop-filter: blur(3px); }
|
||||
.visit-modal-bg.is-open,
|
||||
.upload-modal-bg.is-open { display: flex; }
|
||||
.visit-modal,
|
||||
.upload-modal { width: 100%; max-width: 420px; padding: 1.25rem; background: #fff; border: 1px solid var(--admin-border); border-radius: 14px; box-shadow: 0 18px 50px rgba(31,27,48,.18); }
|
||||
.visit-modal { max-width: 380px; }
|
||||
.visit-modal h3 { margin: 0 0 .75rem; color: var(--admin-text); font-size: 1.05rem; font-weight: 750; }
|
||||
.visit-modal .meta { margin-bottom: .75rem; color: var(--admin-muted); font-size: .82rem; line-height: 1.6; }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: 1rem; }
|
||||
.modal-head h3 { margin: 0; color: var(--admin-text); font-size: 1.05rem; font-weight: 750; }
|
||||
.modal-close { padding: .25rem; color: var(--admin-muted); background: none; border: 0; font-size: 1.15rem; cursor: pointer; }
|
||||
|
||||
/* Authentication screens */
|
||||
.auth-shell { display: flex; align-items: center; justify-content: center; padding: 1.5rem 0; }
|
||||
.login-box { width: 100%; max-width: 410px; padding: 0 1rem; }
|
||||
.auth-language { margin-bottom: .7rem; text-align: right; }
|
||||
.auth-language a { color: var(--admin-muted); font-size: .75rem; }
|
||||
.login-card { padding: 1.6rem 1.4rem; background: #fff; border: 1px solid var(--admin-border); border-radius: 16px; box-shadow: 0 12px 38px rgba(42,34,80,.08); }
|
||||
.auth-head { margin-bottom: 1.1rem; text-align: center; }
|
||||
.auth-icon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; margin: 0 auto .65rem; background: var(--admin-accent-soft); border-radius: 13px; font-size: 1.55rem; }
|
||||
.auth-head h1 { margin: 0; color: var(--admin-text); font-size: 1.25rem; font-weight: 800; }
|
||||
.auth-head p { margin-top: .2rem; color: var(--admin-muted); font-size: .84rem; }
|
||||
.auth-divider { height: 1px; margin: 1rem 0; background: var(--admin-border-soft); border: 0; }
|
||||
.turnstile-wrap { margin-top: .75rem; }
|
||||
.time-columns { margin-bottom: 0 !important; }
|
||||
|
||||
@media (min-width: 1080px) {
|
||||
.admin-shell { padding-bottom: 0; }
|
||||
.bottom-tabs { display: none; }
|
||||
.desktop-nav { display: flex; align-items: center; justify-content: center; gap: .2rem; }
|
||||
.desktop-nav a { padding: .36rem .62rem; color: #625d70; border-radius: 7px; font-size: .78rem; font-weight: 650; text-decoration: none; transition: background .15s,color .15s; }
|
||||
.desktop-nav a:hover { background: #f2f0f7; }
|
||||
.desktop-nav a.is-active { color: #fff; background: var(--admin-accent); }
|
||||
.sticky-actions { bottom: .75rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.top-header .brand { font-size: .95rem; }
|
||||
.brand-version,
|
||||
.top-header-right .admin-name { display: none; }
|
||||
.main-content { padding: 1rem .75rem 1.5rem; }
|
||||
.page-head { align-items: flex-start; }
|
||||
.page-head h1 { font-size: 1.35rem; }
|
||||
.admin-grid,
|
||||
.form-grid,
|
||||
.admin-toggle-list { grid-template-columns: 1fr; }
|
||||
.admin-field-wide { grid-column: auto; }
|
||||
.admin-section-head { grid-template-columns: 36px minmax(0,1fr); padding: .9rem; }
|
||||
.admin-section-icon { width: 36px; height: 36px; }
|
||||
.admin-section-body,
|
||||
.form-card { padding: .9rem; }
|
||||
.sticky-actions .button { width: 100%; }
|
||||
.fc .fc-toolbar { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: .55rem; font-size: .72rem; }
|
||||
.fc .fc-toolbar-chunk:first-child { grid-column: 1; grid-row: 1; justify-self: start; }
|
||||
.fc .fc-toolbar-chunk:nth-child(2) { grid-column: 2; grid-row: 1; justify-self: end; }
|
||||
.fc .fc-toolbar-chunk:last-child { grid-column: 1 / -1; grid-row: 2; width: 100%; overflow-x: auto; }
|
||||
.fc .fc-toolbar-chunk:last-child .fc-button-group { width: 100%; }
|
||||
.fc .fc-toolbar-chunk:last-child .fc-button { flex: 1 0 auto; }
|
||||
.fc .fc-toolbar-title { font-size: .92rem !important; }
|
||||
.fc .fc-button { padding: .22rem .35rem !important; font-size: .68rem !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.page-head { flex-direction: column; }
|
||||
.page-head-actions { width: 100%; }
|
||||
.page-head-actions .button { flex: 1; }
|
||||
.item-card-header { align-items: flex-start; }
|
||||
.media-grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: .55rem; }
|
||||
.media-card img,
|
||||
.media-card .photo-thumb,
|
||||
.media-card .video-thumb { height: 122px; }
|
||||
.media-meta { align-items: flex-start; flex-direction: column; gap: .1rem; }
|
||||
.status-picker { flex-direction: column; }
|
||||
.status-btn { flex-direction: row; justify-content: flex-start; }
|
||||
.inline-control { align-items: stretch; flex-direction: column; }
|
||||
.inline-admin-grid { grid-template-columns: 1fr; align-items: stretch; }
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.bottom-tabs a .tab-label { display: none; }
|
||||
.bottom-tabs a { min-width: 2.8rem; padding: 0 .3rem; }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
@@ -5,12 +5,17 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ title }}</h1>
|
||||
<h1>{{ t.clients_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<form method="post" action="{{ action_url }}">
|
||||
<div class="field">
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">👤</span>
|
||||
<div><h2>{{ title }}</h2></div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<form method="post" action="{{ action_url }}" class="form-grid">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.clients_name }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="name" value="{{ client_name }}" required>
|
||||
@@ -28,41 +33,42 @@
|
||||
<input class="input" type="email" name="email" value="{{ client_email }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.clients_address }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="address" value="{{ client_address }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.clients_notes }}</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" name="notes" rows="3">{{ client_notes }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.clients_color }}</label>
|
||||
<div class="control" style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="color" name="color" value="{{ client_color }}" style="width:3rem;height:2.2rem;padding:0;border:1px solid #ddd;border-radius:6px;cursor:pointer;">
|
||||
<div class="control color-control">
|
||||
<input type="color" name="color" value="{{ client_color }}">
|
||||
<span class="has-text-grey is-size-7">{{ client_color }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field admin-field-wide">
|
||||
<button type="submit" class="button is-primary is-fullwidth">{{ submit_label }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if is_edit %}
|
||||
<hr>
|
||||
<div class="portal-panel">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.portal_link }}</label>
|
||||
<div class="control" style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input class="input is-small" type="text" readonly id="portalUrl" value="" style="flex:1;">
|
||||
<div class="control inline-control">
|
||||
<input class="input is-small" type="text" readonly id="portalUrl" value="">
|
||||
<button type="button" class="button is-small is-info is-outlined" onclick="navigator.clipboard.writeText(document.getElementById('portalUrl').value)">📋</button>
|
||||
</div>
|
||||
<div style="margin-top:0.75rem;text-align:center;">
|
||||
<canvas id="qrCanvas" style="max-width:180px;"></canvas>
|
||||
<div class="portal-qr">
|
||||
<canvas id="qrCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
@@ -71,16 +77,23 @@
|
||||
new QRious({ element: document.getElementById('qrCanvas'), value: url, size: 180, level: 'M' });
|
||||
})();
|
||||
</script>
|
||||
<hr>
|
||||
<div class="danger-zone">
|
||||
{% if client_status == "active" %}
|
||||
<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 }}');" class="mt-3">
|
||||
<button type="submit" class="button is-danger is-outlined is-fullwidth">{{ t.clients_delete }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.clients_title }}</h1>
|
||||
<div>
|
||||
<div class="page-head-actions">
|
||||
{% if show_all %}
|
||||
<a href="/admin/clients?lang={{ lang.code() }}" class="button is-small is-light">{{ t.filter_show_active }}</a>
|
||||
{% else %}
|
||||
@@ -17,19 +17,19 @@
|
||||
</div>
|
||||
|
||||
{% if clients.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.clients_empty }}</p>
|
||||
<p class="empty-state">{{ t.clients_empty }}</p>
|
||||
{% else %}
|
||||
{% for client in &clients %}
|
||||
<div class="item-card">
|
||||
<div class="item-card-header">
|
||||
<a href="/admin/clients/{{ client.id }}/edit?lang={{ lang.code() }}" class="name" style="text-decoration:none;color:inherit;">
|
||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:{{ client.color.as_deref().unwrap_or("#7c6ed4") }};margin-right:6px;vertical-align:middle;"></span>{{ client.name }}
|
||||
<a href="/admin/clients/{{ client.id }}/edit?lang={{ lang.code() }}" class="name card-link">
|
||||
<span class="color-dot" style="background:{{ client.color.as_deref().unwrap_or("#7c6ed4") }}"></span>{{ client.name }}
|
||||
</a>
|
||||
<span class="badge badge-{{ client.status }}">{{ t.client_status(&client.status) }}</span>
|
||||
</div>
|
||||
<div class="item-card-meta">
|
||||
{% if let Some(phone) = client.phone.as_deref() %}
|
||||
<span><a href="tel:{{ phone }}" style="color:inherit;text-decoration:none;">📞 {{ phone }}</a></span>
|
||||
<span><a href="tel:{{ phone }}" class="card-link">📞 {{ phone }}</a></span>
|
||||
{% endif %}
|
||||
{% if let Some(email) = client.email.as_deref() %}
|
||||
<span>✉️ {{ email }}</span>
|
||||
|
||||
@@ -10,27 +10,27 @@
|
||||
</div>
|
||||
|
||||
{% if today_visits.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.dashboard_no_visits }}</p>
|
||||
<p class="empty-state">{{ t.dashboard_no_visits }}</p>
|
||||
{% else %}
|
||||
{% for tv in &today_visits %}
|
||||
<div class="item-card">
|
||||
<div class="item-card-header">
|
||||
<a href="/admin/schedule/{{ tv.visit.id }}/edit?lang={{ lang.code() }}" class="name" style="text-decoration:none;color:inherit;">
|
||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:{{ tv.client_color }};margin-right:6px;vertical-align:middle;"></span>{{ tv.client_name }}
|
||||
<a href="/admin/schedule/{{ tv.visit.id }}/edit?lang={{ lang.code() }}" class="name card-link">
|
||||
<span class="color-dot" style="background:{{ tv.client_color }}"></span>{{ tv.client_name }}
|
||||
</a>
|
||||
<span class="badge badge-visit-{{ tv.visit.status }}">{{ t.visit_status(&tv.visit.status) }}</span>
|
||||
</div>
|
||||
<div class="item-card-meta">
|
||||
<span>🕐 {{ tv.visit.time_start }} — {{ tv.visit.time_end }}</span>
|
||||
{% if !tv.client_phone.is_empty() %}
|
||||
<span><a href="tel:{{ tv.client_phone }}" style="color:inherit;text-decoration:none;">📞 {{ tv.client_phone }}</a></span>
|
||||
<span><a href="tel:{{ tv.client_phone }}" class="card-link">📞 {{ tv.client_phone }}</a></span>
|
||||
{% endif %}
|
||||
{% if !tv.client_address.is_empty() %}
|
||||
<span>📍 {{ tv.client_address }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(notes) = tv.visit.notes.as_deref() %}
|
||||
<div style="color:#888;font-size:0.82rem;margin-top:0.3rem;">{{ notes }}</div>
|
||||
<div class="card-note">{{ notes }}</div>
|
||||
{% endif %}
|
||||
<div class="item-card-actions">
|
||||
{% if tv.visit.status == "scheduled" %}
|
||||
@@ -48,22 +48,22 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Feedbacks -->
|
||||
<h2 style="font-size:1.15rem;font-weight:700;margin:1.5rem 0 0.75rem;">{{ t.dashboard_recent_feedbacks }}</h2>
|
||||
<h2 class="section-title">{{ t.dashboard_recent_feedbacks }}</h2>
|
||||
{% if feedbacks.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.dashboard_no_feedbacks }}</p>
|
||||
<p class="empty-state">{{ t.dashboard_no_feedbacks }}</p>
|
||||
{% else %}
|
||||
{% for fb in &feedbacks %}
|
||||
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" class="item-card" style="border-left:3px solid #7c6cff;display:block;text-decoration:none;color:inherit;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;">
|
||||
<strong style="font-size:0.9rem;">{{ fb.client_name }}</strong>
|
||||
<span style="color:#999;font-size:0.8rem;">{{ fb.visit_date }}</span>
|
||||
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" class="item-card feedback-card">
|
||||
<div class="feedback-head">
|
||||
<strong>{{ fb.client_name }}</strong>
|
||||
<span class="feedback-date">{{ fb.visit_date }}</span>
|
||||
</div>
|
||||
<div style="font-size:0.85rem;color:#4a4570;">{{ fb.feedback }}</div>
|
||||
<div class="feedback-text">{{ fb.feedback }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if feedback_total_pages > 1 %}
|
||||
<div style="display:flex;justify-content:center;gap:0.5rem;margin-top:1rem;">
|
||||
<div class="pagination-row">
|
||||
{% if feedback_page > 1 %}
|
||||
<a href="/admin/?lang={{ lang.code() }}&page={{ feedback_page - 1 }}" class="button is-small is-light">«</a>
|
||||
{% endif %}
|
||||
|
||||
+12
-108
@@ -4,111 +4,15 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.nav_title }} — {% block title %}{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
<style>
|
||||
:root { --accent: #6c63ff; color-scheme: light; }
|
||||
body { padding-bottom: 4rem; min-height: 100vh; background: #f5f5f5; color: #333; }
|
||||
|
||||
/* ── Top bar ── */
|
||||
.top-header {
|
||||
background: #fff; border-bottom: 1px solid #e8e8e8;
|
||||
padding: 0.5rem 1rem; display: flex; align-items: center;
|
||||
justify-content: space-between; position: sticky; top: 0; z-index: 30;
|
||||
}
|
||||
.top-header .brand { font-weight: 700; font-size: 1.1rem; color: #333; text-decoration: none; }
|
||||
.top-header-right { display: flex; align-items: center; gap: 0.75rem; font-size: 0.85rem; }
|
||||
.top-header-right a { color: #888; text-decoration: none; }
|
||||
.top-header-right .admin-name { color: #aaa; }
|
||||
|
||||
/* ── Bottom tabs (mobile nav) ── */
|
||||
.bottom-tabs {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
|
||||
background: #fff; border-top: 1px solid #e8e8e8;
|
||||
display: flex; height: 3.5rem;
|
||||
}
|
||||
.bottom-tabs a {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; text-decoration: none; color: #999;
|
||||
font-size: 0.65rem; font-weight: 600; gap: 0.15rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.bottom-tabs a .tab-icon { font-size: 1.25rem; line-height: 1; }
|
||||
.bottom-tabs a.is-active { color: var(--accent); }
|
||||
|
||||
/* ── Desktop: hide bottom tabs, show top nav ── */
|
||||
.desktop-nav { display: none; }
|
||||
@media (min-width: 769px) {
|
||||
body { padding-bottom: 0; }
|
||||
.bottom-tabs { display: none; }
|
||||
.desktop-nav { display: flex; gap: 0.25rem; }
|
||||
.desktop-nav a {
|
||||
padding: 0.3rem 0.75rem; border-radius: 6px; font-size: 0.9rem;
|
||||
color: #555; text-decoration: none; transition: background 0.15s;
|
||||
}
|
||||
.desktop-nav a:hover { background: #f0f0f0; }
|
||||
.desktop-nav a.is-active { background: var(--accent); color: #fff; }
|
||||
}
|
||||
|
||||
/* ── Content ── */
|
||||
.main-content { padding: 1rem; max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* ── Status badges ── */
|
||||
.badge { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 99px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-new { background: #dbeafe; color: #1e40af; }
|
||||
.badge-in_progress { background: #fef3c7; color: #92400e; }
|
||||
.badge-converted { background: #d1fae5; color: #065f46; }
|
||||
.badge-rejected { background: #fee2e2; color: #991b1b; }
|
||||
.badge-active { background: #d1fae5; color: #065f46; }
|
||||
.badge-archived { background: #e5e7eb; color: #374151; }
|
||||
.badge-visit-scheduled { background: #dbeafe; color: #1e40af; }
|
||||
.badge-visit-completed { background: #d1fae5; color: #065f46; }
|
||||
.badge-visit-cancelled { background: #e5e7eb; color: #374151; }
|
||||
|
||||
/* ── Item cards ── */
|
||||
.item-card {
|
||||
background: #fff; border-radius: 10px; padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.6rem; border: 1px solid #eee;
|
||||
}
|
||||
.item-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; }
|
||||
.item-card-header .name { font-weight: 700; font-size: 1rem; }
|
||||
.item-card-meta { color: #888; font-size: 0.82rem; line-height: 1.5; }
|
||||
.item-card-meta span { margin-right: 1rem; }
|
||||
.item-card-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||
.item-card-actions form { margin: 0; }
|
||||
|
||||
/* ── Small buttons ── */
|
||||
.btn-sm { font-size: 0.78rem !important; padding: 0.25rem 0.6rem !important; height: auto !important; }
|
||||
|
||||
/* ── Page header ── */
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.page-head h1 { font-size: 1.3rem; font-weight: 700; margin: 0; }
|
||||
|
||||
/* ── Forms ── */
|
||||
.form-card { background: #fff; border-radius: 10px; padding: 1.25rem; border: 1px solid #eee; }
|
||||
input, textarea, select,
|
||||
.input, .textarea, .select select {
|
||||
background-color: #fff !important;
|
||||
color: #333 !important;
|
||||
border-color: #dbdbdb !important;
|
||||
}
|
||||
.label, label {
|
||||
color: #363636 !important;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, strong, b, p, span, div, a, td, th, li {
|
||||
color: inherit;
|
||||
}
|
||||
.title, .subtitle, .content, .has-text-dark {
|
||||
color: #363636 !important;
|
||||
}
|
||||
.has-text-grey { color: #7a7a7a !important; }
|
||||
.notification { color: #333 !important; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||
</head>
|
||||
<body>
|
||||
<body class="admin-shell">
|
||||
|
||||
<!-- Top header -->
|
||||
<div class="top-header">
|
||||
<a class="brand" href="/admin/?lang={{ lang.code() }}">🐾 {{ t.nav_title }} <span style="font-size:0.7rem;font-weight:400;color:#aaa;">v{{ t.app_version() }}</span></a>
|
||||
<a class="brand" href="/admin/?lang={{ lang.code() }}">🐾 {{ t.nav_title }} <span class="brand-version">v{{ t.app_version() }}</span></a>
|
||||
<nav class="desktop-nav">
|
||||
<a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}>{{ t.dashboard_title }}</a>
|
||||
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>{{ t.nav_leads }}</a>
|
||||
@@ -134,28 +38,28 @@
|
||||
<!-- Bottom tabs (mobile) -->
|
||||
<nav class="bottom-tabs">
|
||||
<a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">🏠</span>{{ t.dashboard_title }}
|
||||
<span class="tab-icon">🏠</span><span class="tab-label">{{ t.dashboard_title }}</span>
|
||||
</a>
|
||||
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">📋</span>{{ t.nav_leads }}
|
||||
<span class="tab-icon">📋</span><span class="tab-label">{{ t.nav_leads }}</span>
|
||||
</a>
|
||||
<a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">👥</span>{{ t.nav_clients }}
|
||||
<span class="tab-icon">👥</span><span class="tab-label">{{ t.nav_clients }}</span>
|
||||
</a>
|
||||
<a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">📅</span>{{ t.nav_schedule }}
|
||||
<span class="tab-icon">📅</span><span class="tab-label">{{ t.nav_schedule }}</span>
|
||||
</a>
|
||||
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">📷</span>{{ t.nav_media }}
|
||||
<span class="tab-icon">📷</span><span class="tab-label">{{ t.nav_media }}</span>
|
||||
</a>
|
||||
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">💬</span>{{ t.nav_testimonials }}
|
||||
<span class="tab-icon">💬</span><span class="tab-label">{{ t.nav_testimonials }}</span>
|
||||
</a>
|
||||
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">🔑</span>{{ t.nav_users }}
|
||||
<span class="tab-icon">🔑</span><span class="tab-label">{{ t.nav_users }}</span>
|
||||
</a>
|
||||
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">⚙️</span>{{ t.nav_settings }}
|
||||
<span class="tab-icon">⚙️</span><span class="tab-label">{{ t.nav_settings }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
|
||||
{% if leads.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.leads_empty }}</p>
|
||||
<p class="empty-state">{{ t.leads_empty }}</p>
|
||||
{% else %}
|
||||
{% for lead in &leads %}
|
||||
<div class="item-card">
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div class="item-card-meta">
|
||||
{% if let Some(phone) = lead.phone.as_deref() %}
|
||||
<span><a href="tel:{{ phone }}" style="color:inherit;text-decoration:none;">📞 {{ phone }}</a></span>
|
||||
<span><a href="tel:{{ phone }}" class="card-link">📞 {{ phone }}</a></span>
|
||||
{% endif %}
|
||||
{% if let Some(comment) = lead.comment.as_deref() %}
|
||||
<span>💬 {{ comment }}</span>
|
||||
@@ -33,12 +33,6 @@
|
||||
</div>
|
||||
{% if lead.status == "new" || lead.status == "in_progress" %}
|
||||
<div class="item-card-actions">
|
||||
{% if lead.status == "new" %}
|
||||
<form method="post" action="/admin/leads/{{ lead.id }}/status">
|
||||
<input type="hidden" name="status" value="in_progress">
|
||||
<button type="submit" class="button is-small is-info is-outlined btn-sm">{{ t.action_in_progress }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/leads/{{ lead.id }}/convert">
|
||||
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
|
||||
</form>
|
||||
|
||||
+21
-12
@@ -4,27 +4,32 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-box { width: 100%; max-width: 380px; padding: 0 1rem; }
|
||||
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<body class="auth-shell">
|
||||
<div class="login-box">
|
||||
<div class="has-text-right mb-3">
|
||||
<a href="?lang={{ lang.other().code() }}" class="has-text-grey is-size-7">{{ lang.other().label() }}</a>
|
||||
<div class="auth-language">
|
||||
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="has-text-centered mb-4">
|
||||
<p class="is-size-3">🐾</p>
|
||||
<h1 class="is-size-4 has-text-weight-bold">{{ t.nav_title }}</h1>
|
||||
<p class="has-text-grey">{{ t.login_title }}</p>
|
||||
<div class="auth-head">
|
||||
<span class="auth-icon">🐾</span>
|
||||
<h1>{{ t.nav_title }}</h1>
|
||||
<p>{{ t.login_title }}</p>
|
||||
</div>
|
||||
{% 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 class="auth-divider">{% endif %}
|
||||
<form method="post" action="/admin/login/submit">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.users_login }}</label>
|
||||
@@ -34,8 +39,12 @@
|
||||
<label class="label">{{ t.users_password }}</label>
|
||||
<div class="control"><input class="input" type="password" name="password" required></div>
|
||||
</div>
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<div class="cf-turnstile turnstile-wrap" data-sitekey="{{ turnstile_site_key }}" data-theme="light"></div>
|
||||
{% endif %}
|
||||
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
+29
-55
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Client filter -->
|
||||
<div style="margin-bottom:1rem;">
|
||||
<div class="filter-bar">
|
||||
<div class="select is-small">
|
||||
<select onchange="window.location.href='/admin/media?lang={{ lang.code() }}' + (this.value ? '&client_id=' + this.value : '')">
|
||||
<option value="">{{ t.media_all_clients }}</option>
|
||||
@@ -21,83 +21,57 @@
|
||||
</div>
|
||||
|
||||
{% if items.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.media_empty }}</p>
|
||||
<p class="empty-state">{{ t.media_empty }}</p>
|
||||
{% else %}
|
||||
<div class="media-grid">
|
||||
{% 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">
|
||||
<div class="media-meta">
|
||||
<strong>{{ item.client_name }}</strong>
|
||||
{% if let Some(d) = item.visit_date.as_deref() %}
|
||||
<span style="color:#999;">{{ d }}</span>
|
||||
<span class="feedback-date">{{ d }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(cap) = item.media.caption.as_deref() %}
|
||||
<div style="font-size:0.82rem;color:#666;margin-top:0.2rem;">{{ cap }}</div>
|
||||
<div class="media-caption">{{ 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 }}');" class="mt-2">
|
||||
<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>
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.media-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #eee;
|
||||
overflow: hidden;
|
||||
}
|
||||
.media-card img {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.media-card .video-thumb {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.media-info {
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.media-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.media-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
.media-card img, .media-card .video-thumb {
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,21 +5,27 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.media_upload_title }}</h1>
|
||||
<h1>{{ t.media_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<div style="margin-bottom:1rem;font-size:0.9rem;color:#666;">
|
||||
<div><strong>{{ t.schedule_client }}:</strong> {{ client_name }}</div>
|
||||
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
|
||||
</div>
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">📤</span>
|
||||
<div>
|
||||
<h2>{{ t.media_upload_title }}</h2>
|
||||
<p>{{ client_name }} · {{ visit_label }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
|
||||
<form method="post" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
|
||||
<form id="uploadForm" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.media_choose_files }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
|
||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||
</div>
|
||||
<p id="fileCount" class="field-help"></p>
|
||||
<div id="uploadQueue" class="upload-queue"></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -29,7 +35,135 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||
<!-- Progress -->
|
||||
<div id="uploadProgress" class="upload-progress">
|
||||
<div class="upload-progress-head">
|
||||
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||
<span id="uploadPercent">0%</span>
|
||||
</div>
|
||||
<div class="upload-progress-track">
|
||||
<div id="uploadBar" class="upload-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
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');
|
||||
var statusText = document.getElementById('uploadStatusText');
|
||||
var submitBtn = document.getElementById('uploadSubmit');
|
||||
|
||||
filesInput.addEventListener('change', function() {
|
||||
var n = this.files.length;
|
||||
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;
|
||||
|
||||
var data = new FormData(form);
|
||||
var xhr = new XMLHttpRequest();
|
||||
|
||||
progress.style.display = 'block';
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '{{ t.media_upload_sending }}';
|
||||
statusText.textContent = '{{ t.media_upload_sending }}';
|
||||
bar.classList.remove('is-processing');
|
||||
bar.style.width = '0%';
|
||||
percent.textContent = '0%';
|
||||
|
||||
xhr.upload.addEventListener('progress', function(ev) {
|
||||
if (!ev.lengthComputable) return;
|
||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||
bar.style.width = pct + '%';
|
||||
percent.textContent = pct + '%';
|
||||
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 = '{{ 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() {
|
||||
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 }}';
|
||||
});
|
||||
|
||||
xhr.open('POST', form.action);
|
||||
xhr.send(data);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a href="/admin/schedule/new?lang={{ lang.code() }}" class="button is-primary is-small">+ {{ t.schedule_new }}</a>
|
||||
</div>
|
||||
|
||||
<div class="form-card" id="calendar-wrap" style="padding:0.5rem;">
|
||||
<div class="form-card" id="calendar-wrap">
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
@@ -18,38 +18,6 @@
|
||||
{% if lang == Lang::Ru %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.17/locales/ru.global.min.js"></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
#calendar-wrap { overflow: hidden; }
|
||||
.fc { font-size: 0.85rem; color: #333; }
|
||||
.fc .fc-daygrid-day-number,
|
||||
.fc .fc-col-header-cell-cushion,
|
||||
.fc .fc-list-day-text,
|
||||
.fc .fc-list-day-side-text { color: #333 !important; }
|
||||
.fc th, .fc td { color: #333; }
|
||||
.fc .fc-toolbar-title { color: #333 !important; }
|
||||
.fc .fc-button { color: #fff !important; }
|
||||
.fc .fc-toolbar { flex-wrap: wrap; gap: 0.3rem; }
|
||||
.fc .fc-toolbar-title { font-size: 1.1rem !important; }
|
||||
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
|
||||
.fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; }
|
||||
.fc .fc-day-today { background: #eef2ff !important; }
|
||||
.fc .fc-day.day-weekend { background: #faf5f0; }
|
||||
.fc .fc-day-today.day-weekend { background: #eef2ff !important; }
|
||||
@media (max-width: 768px) {
|
||||
.fc .fc-toolbar { font-size: 0.75rem; }
|
||||
.fc .fc-toolbar-title { font-size: 0.95rem !important; }
|
||||
.fc .fc-button { padding: 0.2rem 0.35rem !important; font-size: 0.72rem !important; }
|
||||
}
|
||||
.visit-modal-bg { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.35); z-index:100; align-items:center; justify-content:center; }
|
||||
.visit-modal-bg.is-open { display:flex; }
|
||||
.visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); }
|
||||
.visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; }
|
||||
.visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; }
|
||||
.visit-modal .actions { display:flex; gap:0.5rem; flex-wrap:wrap; }
|
||||
.visit-modal .actions form { margin:0; }
|
||||
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
||||
</style>
|
||||
|
||||
<div class="visit-modal-bg" id="visitModal">
|
||||
<div class="visit-modal">
|
||||
<h3><span class="color-dot" id="vmDot"></span><span id="vmTitle"></span></h3>
|
||||
@@ -58,14 +26,10 @@
|
||||
<div id="vmAddress"></div>
|
||||
<div id="vmAdmin"></div>
|
||||
<div id="vmTime"></div>
|
||||
<div id="vmNotes" style="margin-top:0.3rem;"></div>
|
||||
</div>
|
||||
<div id="vmStatus" style="margin-bottom:0.75rem;"></div>
|
||||
<div class="actions" id="vmActions"></div>
|
||||
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
|
||||
<a id="vmEditLink" href="#" class="button is-info is-small" style="flex:1;">{{ t.schedule_edit_title }}</a>
|
||||
<button class="button is-light is-small" style="flex:1;" onclick="closeModal()">OK</button>
|
||||
<div id="vmNotes" class="mt-2"></div>
|
||||
</div>
|
||||
<div id="vmStatus" class="mb-4"></div>
|
||||
<a id="vmEditLink" href="#" class="button is-primary is-fullwidth">📋 {{ t.schedule_edit_title }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,30 +48,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
timeZone: '{{ timezone }}',
|
||||
initialView: window.innerWidth < 768 ? 'listWeek' : 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
left: 'prev,next,today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||
},
|
||||
events: '/admin/schedule/events',
|
||||
eventDidMount: function(info) {
|
||||
var status = info.event.extendedProps.status;
|
||||
if (status === 'completed') info.el.classList.add('ev-completed');
|
||||
if (status === 'cancelled') info.el.classList.add('ev-cancelled');
|
||||
},
|
||||
eventClick: function(info) {
|
||||
info.jsEvent.preventDefault();
|
||||
const ev = info.event;
|
||||
const p = ev.extendedProps;
|
||||
document.getElementById('vmDot').style.background = p.client_color || '#7c6ed4';
|
||||
document.getElementById('vmTitle').textContent = p.client_name;
|
||||
document.getElementById('vmClient').innerHTML = p.client_phone ? ('<a href="tel:' + p.client_phone + '" style="color:inherit;text-decoration:none;">📞 ' + p.client_phone + '</a>') : '';
|
||||
document.getElementById('vmClient').innerHTML = p.client_phone ? ('<a href="tel:' + p.client_phone + '" class="card-link">📞 ' + p.client_phone + '</a>') : '';
|
||||
document.getElementById('vmAddress').textContent = p.client_address ? ('📍 ' + p.client_address) : '';
|
||||
document.getElementById('vmAdmin').textContent = '👤 ' + p.admin_name;
|
||||
document.getElementById('vmTime').textContent = '🕐 ' + p.time_start + ' — ' + p.time_end;
|
||||
document.getElementById('vmNotes').textContent = p.notes || '';
|
||||
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
|
||||
document.getElementById('vmStatus').innerHTML = badge;
|
||||
let actions = '';
|
||||
if (p.status === 'scheduled') {
|
||||
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/done"><button class="button is-small is-success is-outlined">{{ t.schedule_mark_done }}</button></form>';
|
||||
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/cancel"><button class="button is-small is-danger is-outlined">{{ t.schedule_cancel }}</button></form>';
|
||||
}
|
||||
document.getElementById('vmActions').innerHTML = actions;
|
||||
document.getElementById('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang;
|
||||
document.getElementById('visitModal').classList.add('is-open');
|
||||
},
|
||||
|
||||
@@ -5,24 +5,24 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.schedule_edit_title }}</h1>
|
||||
<h1>{{ t.schedule_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">📋</span>
|
||||
<div>
|
||||
<h2>{{ t.schedule_edit_title }}</h2>
|
||||
<p>{{ client.name }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<form method="post" action="/admin/schedule/{{ visit.id }}/save">
|
||||
<!-- Client -->
|
||||
<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>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<!-- Time -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_default_time }}</label>
|
||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
||||
<div class="columns is-mobile time-columns">
|
||||
<div class="column">
|
||||
<div class="control">
|
||||
<input class="input" type="time" name="time_start" value="{{ visit.time_start }}" required>
|
||||
@@ -70,14 +70,17 @@
|
||||
<!-- Status -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_status }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="status">
|
||||
<option value="scheduled" {% if visit.status == "scheduled" %}selected{% endif %}>{{ t.visit_status_scheduled }}</option>
|
||||
<option value="completed" {% if visit.status == "completed" %}selected{% endif %}>{{ t.visit_status_completed }}</option>
|
||||
<option value="cancelled" {% if visit.status == "cancelled" %}selected{% endif %}>{{ t.visit_status_cancelled }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="status" id="statusInput" value="{{ visit.status }}">
|
||||
<div class="status-picker">
|
||||
<button type="button" class="status-btn status-btn-scheduled {% if visit.status == "scheduled" %}is-active{% endif %}" data-value="scheduled">
|
||||
<span class="status-btn-icon">📅</span>{{ t.visit_status_scheduled }}
|
||||
</button>
|
||||
<button type="button" class="status-btn status-btn-completed {% if visit.status == "completed" %}is-active{% endif %}" data-value="completed">
|
||||
<span class="status-btn-icon">✅</span>{{ t.visit_status_completed }}
|
||||
</button>
|
||||
<button type="button" class="status-btn status-btn-cancelled {% if visit.status == "cancelled" %}is-active{% endif %}" data-value="cancelled">
|
||||
<span class="status-btn-icon">✕</span>{{ t.visit_status_cancelled }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,66 +101,82 @@
|
||||
</div>
|
||||
|
||||
{% if let Some(fb) = visit.client_feedback.as_deref() %}
|
||||
<div style="margin-bottom:1rem;">
|
||||
<div class="mb-4">
|
||||
<label class="label">{{ t.schedule_client_feedback }}</label>
|
||||
<div style="background:#f0f0ff;border-radius:8px;padding:0.6rem 0.85rem;font-size:0.9rem;color:#4a4570;">{{ fb }}</div>
|
||||
<div class="admin-note admin-note-info">{{ fb }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr style="margin:1rem 0;">
|
||||
<hr>
|
||||
|
||||
<!-- Media -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||
<label class="label" style="margin:0;">{{ t.nav_media }}</label>
|
||||
<div class="section-inline-head">
|
||||
<label class="label">{{ t.nav_media }}</label>
|
||||
<button type="button" class="button is-info is-small is-outlined" onclick="document.getElementById('uploadModal').classList.add('is-open')">+ {{ t.media_upload }}</button>
|
||||
</div>
|
||||
{% if media.is_empty() %}
|
||||
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ t.media_empty }}</p>
|
||||
<p class="empty-state mb-4">{{ t.media_empty }}</p>
|
||||
{% else %}
|
||||
<div class="visit-media-grid">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
<hr style="margin:1rem 0;">
|
||||
<hr>
|
||||
|
||||
<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;">
|
||||
<hr>
|
||||
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
||||
<button type="submit" class="button is-danger is-outlined is-fullwidth is-small">{{ t.schedule_delete }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="upload-modal-bg" id="uploadModal">
|
||||
<div class="upload-modal">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
||||
<button type="button" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;" onclick="document.getElementById('uploadModal').classList.remove('is-open')">✕</button>
|
||||
<div class="modal-head">
|
||||
<h3>{{ t.media_upload_title }}</h3>
|
||||
<button type="button" id="uploadModalClose" class="modal-close">✕</button>
|
||||
</div>
|
||||
<form method="post" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
||||
<form id="uploadForm" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.media_choose_files }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
|
||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||
</div>
|
||||
<p id="fileCount" class="field-help"></p>
|
||||
<div id="uploadQueue" class="upload-queue"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.media_caption }}</label>
|
||||
@@ -165,72 +184,162 @@
|
||||
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||
|
||||
<!-- Progress -->
|
||||
<div id="uploadProgress" class="upload-progress">
|
||||
<div class="upload-progress-head">
|
||||
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||
<span id="uploadPercent">0%</span>
|
||||
</div>
|
||||
<div class="upload-progress-track">
|
||||
<div id="uploadBar" class="upload-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.visit-media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.visit-media-item {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
overflow: hidden;
|
||||
}
|
||||
.visit-media-item img {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.visit-media-item .video-thumb-sm {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.visit-media-item .media-cap {
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
padding: 0.2rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.upload-modal-bg {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.upload-modal-bg.is-open {
|
||||
display: flex;
|
||||
}
|
||||
.upload-modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.getElementById('uploadModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) this.classList.remove('is-open');
|
||||
// Status picker
|
||||
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.status-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
||||
btn.classList.add('is-active');
|
||||
document.getElementById('statusInput').value = btn.dataset.value;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var modal = document.getElementById('uploadModal');
|
||||
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');
|
||||
var statusText = document.getElementById('uploadStatusText');
|
||||
var submitBtn = document.getElementById('uploadSubmit');
|
||||
|
||||
// Close modal on backdrop click
|
||||
modal.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
document.getElementById('uploadModalClose').addEventListener('click', closeModal);
|
||||
|
||||
function closeModal() {
|
||||
if (submitBtn.disabled) return; // prevent close during upload
|
||||
modal.classList.remove('is-open');
|
||||
}
|
||||
|
||||
// Show selected file count
|
||||
filesInput.addEventListener('change', function() {
|
||||
var n = this.files.length;
|
||||
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();
|
||||
if (!filesInput.files.length) return;
|
||||
|
||||
var data = new FormData(form);
|
||||
var xhr = new XMLHttpRequest();
|
||||
|
||||
// Show progress bar, disable submit
|
||||
progress.style.display = 'block';
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '{{ t.media_upload_sending }}';
|
||||
statusText.textContent = '{{ t.media_upload_sending }}';
|
||||
bar.classList.remove('is-processing');
|
||||
bar.style.width = '0%';
|
||||
percent.textContent = '0%';
|
||||
|
||||
xhr.upload.addEventListener('progress', function(ev) {
|
||||
if (!ev.lengthComputable) return;
|
||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||
bar.style.width = pct + '%';
|
||||
percent.textContent = pct + '%';
|
||||
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 = '{{ 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() {
|
||||
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 }}';
|
||||
});
|
||||
|
||||
xhr.open('POST', form.action);
|
||||
xhr.send(data);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+279
-169
@@ -5,242 +5,352 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.schedule_new_title }}</h1>
|
||||
<h1>{{ t.schedule_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<form method="post" action="/admin/schedule/create" id="visitForm">
|
||||
<!-- Client -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_client }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="client_id" required>
|
||||
<option value="">—</option>
|
||||
{% for c in &clients %}
|
||||
<option value="{{ c.id }}">{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">📅</span>
|
||||
<div><h2>{{ t.schedule_new_title }}</h2></div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<form method="post" action="/admin/schedule/create" id="visitForm">
|
||||
|
||||
<!-- Client -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_client }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="client_id" required>
|
||||
<option value="">—</option>
|
||||
{% for c in &clients %}
|
||||
<option value="{{ c.id }}">{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_admin }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="user_id">
|
||||
{% for u in &users %}
|
||||
<option value="{{ u.id }}" {% if u.id.unwrap() == current_user_id %}selected{% endif %}>
|
||||
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Admin -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_admin }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="user_id">
|
||||
{% for u in &users %}
|
||||
<option value="{{ u.id }}" {% if u.id.unwrap() == current_user_id %}selected{% endif %}>
|
||||
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default time -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_default_time }}</label>
|
||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
||||
<div class="column">
|
||||
<div class="control">
|
||||
<input class="input" type="time" id="defaultStart" value="18:00">
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="control">
|
||||
<input class="input" type="time" id="defaultEnd" value="19:00">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Default time -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_default_time }}</label>
|
||||
<div class="time-row">
|
||||
<div class="time-block">
|
||||
<span class="time-lbl">{{ t.schedule_time_start }}</span>
|
||||
<input class="input" type="time" id="defaultStart" value="18:00">
|
||||
</div>
|
||||
<div class="time-sep">—</div>
|
||||
<div class="time-block">
|
||||
<span class="time-lbl">{{ t.schedule_time_end }}</span>
|
||||
<input class="input" type="time" id="defaultEnd" value="19:00">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add individual date -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_pick_dates }}</label>
|
||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
||||
<div class="column">
|
||||
<div class="control">
|
||||
<input class="input" type="date" id="pickDate">
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button type="button" class="button is-info" id="addDateBtn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Calendar -->
|
||||
<div class="field">
|
||||
<div class="cal-toolbar">
|
||||
<button type="button" id="rangeModeBtn" class="button is-small is-outlined is-info">⇔ Выбрать диапазон</button>
|
||||
<button type="button" id="resetBtn" class="button is-small is-outlined is-danger" style="display:none;">✕ Сбросить</button>
|
||||
</div>
|
||||
|
||||
<!-- Date range fill -->
|
||||
<div class="field">
|
||||
<label class="label is-small has-text-grey">{{ t.schedule_range_from }} — {{ t.schedule_range_to }}</label>
|
||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
||||
<div class="column">
|
||||
<input class="input" type="date" id="rangeFrom">
|
||||
</div>
|
||||
<div class="column">
|
||||
<input class="input" type="date" id="rangeTo">
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button type="button" class="button is-info is-outlined" id="fillRangeBtn">{{ t.schedule_fill_range }}</button>
|
||||
</div>
|
||||
<div class="sched-cal">
|
||||
<div class="cal-nav">
|
||||
<button type="button" id="calPrev">◀</button>
|
||||
<span id="calTitle"></span>
|
||||
<button type="button" id="calNext">▶</button>
|
||||
</div>
|
||||
<div class="cal-grid" id="calGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected days list -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_selected_days }}</label>
|
||||
<div id="daysList">
|
||||
<p class="has-text-grey is-size-7" id="noDaysMsg">{{ t.schedule_no_days }}</p>
|
||||
</div>
|
||||
<!-- Selected days -->
|
||||
<div class="field" id="selectedSection" style="display:none;">
|
||||
<label class="label">{{ t.schedule_selected_days }} <span id="selectedCount" class="tag is-info is-light" style="margin-left:0.4rem;"></span></label>
|
||||
<div id="daysList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_notes }}</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" name="notes" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_notes }}</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" name="notes" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="days_json" id="daysJson" value="[]">
|
||||
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
|
||||
|
||||
<!-- Hidden days data -->
|
||||
<input type="hidden" name="days_json" id="daysJson" value="[]">
|
||||
|
||||
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.day-row {
|
||||
display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0;
|
||||
border-bottom: 1px solid #f0f0f0; flex-wrap: wrap;
|
||||
}
|
||||
.day-row .day-date { font-weight: 600; min-width: 6rem; font-size: 0.9rem; }
|
||||
.day-row input[type="time"] { width: 7rem; padding: 0.2rem 0.4rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.85rem; }
|
||||
.day-row .remove-btn { color: #e55; cursor: pointer; font-size: 0.8rem; margin-left: auto; background: none; border: none; }
|
||||
</style>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const days = new Map(); // date string -> {start, end}
|
||||
const removeLabel = '{{ t.schedule_remove_day }}';
|
||||
const weekdays = '{{ lang.code() }}' === 'ru'
|
||||
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб']
|
||||
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
(function() {
|
||||
|
||||
const IS_RU = '{{ lang.code() }}' === 'ru';
|
||||
const TZ = '{{ timezone }}';
|
||||
const WDAYS = IS_RU ? ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'] : ['Mo','Tu','We','Th','Fr','Sa','Su'];
|
||||
const MONTHS = IS_RU
|
||||
? ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']
|
||||
: ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
|
||||
const days = new Map(); // dateStr -> {start, end}
|
||||
let viewYear, viewMonth;
|
||||
let rangeMode = false;
|
||||
let rangeStart = null; // dateStr of first tap in range mode
|
||||
|
||||
const now = new Date();
|
||||
const todayStr_ = tzDateStr(now);
|
||||
viewYear = parseInt(todayStr_.slice(0, 4));
|
||||
viewMonth = parseInt(todayStr_.slice(5, 7)) - 1; // 0-based
|
||||
|
||||
function isoDate(y, m, d) {
|
||||
return y + '-' + String(m+1).padStart(2,'0') + '-' + String(d).padStart(2,'0');
|
||||
}
|
||||
|
||||
function getDefaults() {
|
||||
return {
|
||||
start: document.getElementById('defaultStart').value || '18:00',
|
||||
end: document.getElementById('defaultEnd').value || '19:00'
|
||||
end: document.getElementById('defaultEnd').value || '19:00'
|
||||
};
|
||||
}
|
||||
|
||||
function addDay(dateStr) {
|
||||
if (!dateStr || days.has(dateStr)) return;
|
||||
function addDay(ds) {
|
||||
if (!ds || days.has(ds)) return;
|
||||
const def = getDefaults();
|
||||
days.set(dateStr, { start: def.start, end: def.end });
|
||||
renderDays();
|
||||
days.set(ds, { start: def.start, end: def.end });
|
||||
}
|
||||
|
||||
function removeDay(dateStr) {
|
||||
days.delete(dateStr);
|
||||
renderDays();
|
||||
function toggleDay(ds) {
|
||||
if (days.has(ds)) { days.delete(ds); } else { addDay(ds); }
|
||||
}
|
||||
|
||||
function renderDays() {
|
||||
const list = document.getElementById('daysList');
|
||||
const msg = document.getElementById('noDaysMsg');
|
||||
const btn = document.getElementById('submitBtn');
|
||||
// Получить текущую дату в нужном TZ как строку YYYY-MM-DD
|
||||
function tzDateStr(d) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).formatToParts(d);
|
||||
const p = {};
|
||||
parts.forEach(function(x) { p[x.type] = x.value; });
|
||||
return p.year + '-' + p.month + '-' + p.day;
|
||||
}
|
||||
|
||||
// Remove old day rows
|
||||
list.querySelectorAll('.day-row').forEach(el => el.remove());
|
||||
function fillRange(from, to) {
|
||||
if (from > to) { let t = from; from = to; to = t; }
|
||||
// Используем полдень чтобы избежать проблем с переходом суток при смене DST
|
||||
let cur = new Date(from + 'T12:00:00');
|
||||
const end = new Date(to + 'T12:00:00');
|
||||
while (cur <= end) {
|
||||
addDay(tzDateStr(cur));
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render calendar ──────────────────────────────────────
|
||||
function renderCal() {
|
||||
document.getElementById('calTitle').textContent = MONTHS[viewMonth] + ' ' + viewYear;
|
||||
|
||||
const grid = document.getElementById('calGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
// Weekday headers
|
||||
WDAYS.forEach(function(wd, i) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'cal-wday' + (i >= 5 ? ' is-weekend' : '');
|
||||
cell.textContent = wd;
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
|
||||
// First day of month (Mon=0 for our grid)
|
||||
const first = new Date(viewYear, viewMonth, 1);
|
||||
let startDow = first.getDay(); // 0=Sun
|
||||
startDow = (startDow === 0) ? 6 : startDow - 1; // shift to Mon=0
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const todayStr = todayStr_;
|
||||
|
||||
// Empty cells before first day
|
||||
for (let i = 0; i < startDow; i++) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'cal-day is-empty';
|
||||
grid.appendChild(empty);
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const ds = isoDate(viewYear, viewMonth, d);
|
||||
const cell = document.createElement('div');
|
||||
let cls = 'cal-day';
|
||||
if (ds === todayStr) cls += ' is-today';
|
||||
if (days.has(ds)) cls += ' is-selected';
|
||||
if (ds === rangeStart) cls += ' is-range-start';
|
||||
cell.className = cls;
|
||||
cell.textContent = d;
|
||||
cell.dataset.date = ds;
|
||||
cell.addEventListener('click', onDayClick);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function onDayClick(e) {
|
||||
const ds = e.currentTarget.dataset.date;
|
||||
if (!ds) return;
|
||||
if (rangeMode) {
|
||||
if (days.has(ds)) {
|
||||
days.delete(ds);
|
||||
if (rangeStart === ds) rangeStart = null;
|
||||
} else if (!rangeStart) {
|
||||
rangeStart = ds;
|
||||
} else {
|
||||
fillRange(rangeStart, ds);
|
||||
rangeStart = null;
|
||||
}
|
||||
} else {
|
||||
toggleDay(ds);
|
||||
}
|
||||
|
||||
renderCal();
|
||||
renderList();
|
||||
}
|
||||
|
||||
// ── Render selected days list ────────────────────────────
|
||||
function renderList() {
|
||||
const list = document.getElementById('daysList');
|
||||
const section = document.getElementById('selectedSection');
|
||||
const count = document.getElementById('selectedCount');
|
||||
const btn = document.getElementById('submitBtn');
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
if (days.size === 0) {
|
||||
msg.style.display = '';
|
||||
section.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
document.getElementById('daysJson').value = '[]';
|
||||
resetBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
msg.style.display = 'none';
|
||||
resetBtn.style.display = '';
|
||||
|
||||
section.style.display = '';
|
||||
btn.disabled = false;
|
||||
count.textContent = days.size;
|
||||
|
||||
// Sort by date
|
||||
const sorted = [...days.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
const sorted = [...days.entries()].sort((a,b) => a[0].localeCompare(b[0]));
|
||||
|
||||
sorted.forEach(([dateStr, times]) => {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const wd = weekdays[d.getDay()];
|
||||
const label = dateStr.split('-').reverse().join('.') + ' ' + wd;
|
||||
sorted.forEach(function([ds, times]) {
|
||||
const d = new Date(ds + 'T00:00:00');
|
||||
const dow = IS_RU
|
||||
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'][d.getDay()]
|
||||
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
||||
const parts = ds.split('-');
|
||||
const label = parts[2] + '.' + parts[1] + ' ' + dow; // DD.MM Вт
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'day-row';
|
||||
row.innerHTML = `
|
||||
<span class="day-date">${label}</span>
|
||||
<input type="time" value="${times.start}" data-date="${dateStr}" data-field="start">
|
||||
<span>—</span>
|
||||
<input type="time" value="${times.end}" data-date="${dateStr}" data-field="end">
|
||||
<button type="button" class="remove-btn" data-date="${dateStr}">${removeLabel}</button>
|
||||
`;
|
||||
row.innerHTML =
|
||||
'<span class="day-date">' + label + '</span>' +
|
||||
'<div class="day-times">' +
|
||||
'<div class="time-badge-wrap">' +
|
||||
'<span class="time-badge-label">' + times.start + '</span>' +
|
||||
'<input type="time" class="time-badge-input" value="' + times.start + '" data-date="' + ds + '" data-field="start">' +
|
||||
'</div>' +
|
||||
'<span class="time-sep">—</span>' +
|
||||
'<div class="time-badge-wrap">' +
|
||||
'<span class="time-badge-label">' + times.end + '</span>' +
|
||||
'<input type="time" class="time-badge-input" value="' + times.end + '" data-date="' + ds + '" data-field="end">' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="day-rm" data-date="' + ds + '" title="Убрать">✕</button>';
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
// Update hidden JSON
|
||||
updateJson();
|
||||
|
||||
// Bind events
|
||||
list.querySelectorAll('input[type="time"]').forEach(inp => {
|
||||
list.querySelectorAll('.time-badge-input').forEach(function(inp) {
|
||||
inp.addEventListener('change', function() {
|
||||
const dt = this.dataset.date;
|
||||
const field = this.dataset.field;
|
||||
if (days.has(dt)) {
|
||||
days.get(dt)[field] = this.value;
|
||||
const d = days.get(this.dataset.date);
|
||||
if (d) {
|
||||
d[this.dataset.field] = this.value;
|
||||
this.previousElementSibling.textContent = this.value;
|
||||
updateJson();
|
||||
}
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.remove-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
removeDay(this.dataset.date);
|
||||
list.querySelectorAll('.day-rm').forEach(function(b) {
|
||||
b.addEventListener('click', function() {
|
||||
days.delete(this.dataset.date);
|
||||
renderCal();
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
updateJson();
|
||||
}
|
||||
|
||||
function updateJson() {
|
||||
const arr = [...days.entries()].map(([date, t]) => ({
|
||||
date: date,
|
||||
time_start: t.start,
|
||||
time_end: t.end
|
||||
}));
|
||||
const arr = [...days.entries()].map(function([date, t]) {
|
||||
return { date: date, time_start: t.start, time_end: t.end };
|
||||
});
|
||||
document.getElementById('daysJson').value = JSON.stringify(arr);
|
||||
}
|
||||
|
||||
document.getElementById('addDateBtn').addEventListener('click', function() {
|
||||
const v = document.getElementById('pickDate').value;
|
||||
addDay(v);
|
||||
document.getElementById('pickDate').value = '';
|
||||
// ── Navigation ───────────────────────────────────────────
|
||||
document.getElementById('calPrev').addEventListener('click', function() {
|
||||
viewMonth--;
|
||||
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
||||
renderCal();
|
||||
});
|
||||
document.getElementById('calNext').addEventListener('click', function() {
|
||||
viewMonth++;
|
||||
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
||||
renderCal();
|
||||
});
|
||||
|
||||
document.getElementById('pickDate').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('addDateBtn').click(); }
|
||||
});
|
||||
|
||||
document.getElementById('fillRangeBtn').addEventListener('click', function() {
|
||||
const from = document.getElementById('rangeFrom').value;
|
||||
const to = document.getElementById('rangeTo').value;
|
||||
if (!from || !to || from > to) return;
|
||||
let cur = new Date(from + 'T00:00:00');
|
||||
const end = new Date(to + 'T00:00:00');
|
||||
while (cur <= end) {
|
||||
const ds = cur.toISOString().slice(0, 10);
|
||||
addDay(ds);
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
// ── Range mode toggle ────────────────────────────────────
|
||||
document.getElementById('rangeModeBtn').addEventListener('click', function() {
|
||||
rangeMode = !rangeMode;
|
||||
rangeStart = null;
|
||||
if (rangeMode) {
|
||||
this.classList.remove('is-outlined', 'is-info');
|
||||
this.classList.add('is-warning');
|
||||
this.textContent = '✕ Выбрать отдельные дни';
|
||||
} else {
|
||||
this.classList.remove('is-warning');
|
||||
this.classList.add('is-outlined', 'is-info');
|
||||
this.textContent = '⇔ Выбрать диапазон';
|
||||
}
|
||||
document.getElementById('rangeFrom').value = '';
|
||||
document.getElementById('rangeTo').value = '';
|
||||
renderCal();
|
||||
});
|
||||
|
||||
// Set default pick date to today
|
||||
document.getElementById('pickDate').valueAsDate = new Date();
|
||||
document.getElementById('resetBtn').addEventListener('click', function() {
|
||||
days.clear();
|
||||
rangeStart = null;
|
||||
renderCal();
|
||||
renderList();
|
||||
});
|
||||
|
||||
// ── Default time change → update existing days ───────────
|
||||
// (only updates days that still have the old default)
|
||||
// kept simple: doesn't retroactively update already-added days
|
||||
|
||||
renderCal();
|
||||
renderList();
|
||||
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+322
-26
@@ -5,46 +5,342 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.settings_title }}</h1>
|
||||
<div>
|
||||
<h1>{{ t.settings_title }}</h1>
|
||||
<p>{{ t.settings_intro }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="notification is-success is-light">{{ t.settings_saved }}</div>
|
||||
<div class="notification is-success is-light admin-message">{{ t.settings_saved }}</div>
|
||||
{% endif %}
|
||||
{% if let Some(message) = error %}
|
||||
<div class="notification is-danger is-light admin-message">{{ 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 %}">
|
||||
<form id="settingsForm" class="admin-form" method="post" action="/admin/settings/save">
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">🌐</span>
|
||||
<div>
|
||||
<h2>{{ t.settings_section_general }}</h2>
|
||||
<p>{{ t.settings_section_general_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="contactInfo">{{ t.settings_contact_info }}</label>
|
||||
<div class="control">
|
||||
<input id="contactInfo" class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="siteDomain">{{ t.settings_site_domain }}</label>
|
||||
<div class="control">
|
||||
<input id="siteDomain" class="input" type="url" 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 admin-field">
|
||||
<label class="label" for="timezone">{{ t.settings_timezone }}</label>
|
||||
<div class="control">
|
||||
<input id="timezone" 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 admin-field admin-field-wide">
|
||||
<label class="label" for="pricingInfo">{{ t.settings_pricing_info }}</label>
|
||||
<div class="control">
|
||||
<textarea id="pricingInfo" class="textarea" name="pricing_info" rows="3" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="seoKeywordsInput">{{ t.settings_seo_keywords }}</label>
|
||||
<div class="control">
|
||||
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3" placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
<div id="seoPreview" class="seo-preview" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_telegram_chat_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="telegram_chat_id" value="{% for s in &settings %}{% if s.key == "telegram_chat_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">☁️</span>
|
||||
<div>
|
||||
<div class="admin-title-row">
|
||||
<h2>{{ t.settings_section_storage }}</h2>
|
||||
<span class="status-pill{% if r2_enabled_checked %} is-enabled{% endif %}">
|
||||
{% if r2_enabled_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ t.settings_r2_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<label class="admin-toggle" for="r2Enabled">
|
||||
<input id="r2Enabled" type="checkbox" name="r2_enabled" value="true"{% if r2_enabled_checked %} checked{% endif %}>
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span>
|
||||
<strong>{{ t.settings_r2_enabled }}</strong>
|
||||
<small>{{ t.settings_r2_help }}</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="admin-note admin-note-info">{{ t.settings_r2_migration_help }}</div>
|
||||
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="r2AccountId">{{ t.settings_r2_account_id }}</label>
|
||||
<div class="control">
|
||||
<input id="r2AccountId" class="input is-family-monospace" type="text" name="r2_account_id" maxlength="32" autocomplete="off" spellcheck="false" placeholder="0123456789abcdef0123456789abcdef" value="{% for s in &settings %}{% if s.key == "r2_account_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="r2Bucket">{{ t.settings_r2_bucket }}</label>
|
||||
<div class="control">
|
||||
<input id="r2Bucket" class="input is-family-monospace" type="text" name="r2_bucket" autocomplete="off" spellcheck="false" placeholder="pet-media" value="{% for s in &settings %}{% if s.key == "r2_bucket" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="r2AccessKey">{{ t.settings_r2_access_key_id }}</label>
|
||||
<div class="control">
|
||||
<input id="r2AccessKey" class="input is-family-monospace" type="text" name="r2_access_key_id" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "r2_access_key_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="r2Secret">{{ t.settings_r2_secret_access_key }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="r2Secret" class="input is-family-monospace" type="password" name="r2_secret_access_key" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="field-help">{% if r2_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if r2_secret_configured %}
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_r2_secret_access_key" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_contact_info }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">🔔</span>
|
||||
<div>
|
||||
<div class="admin-title-row">
|
||||
<h2>{{ t.settings_section_notifications }}</h2>
|
||||
<span class="status-pill{% if client_notifications_checked %} is-enabled{% endif %}">
|
||||
{% if client_notifications_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ t.settings_section_notifications_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<label class="admin-toggle" for="clientNotifications">
|
||||
<input id="clientNotifications" type="checkbox" name="client_notifications_enabled" value="true"{% if client_notifications_checked %} checked{% endif %}>
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span>
|
||||
<strong>{{ t.settings_client_notifications_enabled }}</strong>
|
||||
<small>{{ t.settings_client_notifications_help }}</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="admin-subsection">
|
||||
<h3>Web Push / VAPID</h3>
|
||||
<div class="admin-note admin-note-warning">
|
||||
<p>{{ t.settings_vapid_warning }}</p>
|
||||
<p>{{ t.settings_vapid_generate }} <code>cargo run --bin generate_vapid</code></p>
|
||||
</div>
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="vapidPublic">{{ t.settings_vapid_public_key }}</label>
|
||||
<div class="control">
|
||||
<input id="vapidPublic" class="input is-family-monospace" type="text" name="vapid_public_key" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "vapid_public_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="vapidPrivate">{{ t.settings_vapid_private_key }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="vapidPrivate" class="input is-family-monospace" type="password" name="vapid_private_key" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="field-help">{% if vapid_private_key_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if vapid_private_key_configured %}
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_vapid_private_key" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="vapidSubject">{{ t.settings_vapid_subject }}</label>
|
||||
<div class="control">
|
||||
<input id="vapidSubject" 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>
|
||||
</div>
|
||||
|
||||
<details class="admin-details">
|
||||
<summary>{{ t.settings_push_subscribers }} <span>{{ push_subscribers.len() }}</span></summary>
|
||||
<div class="admin-details-body">
|
||||
{% if push_subscribers.is_empty() %}
|
||||
<p class="field-help">{{ t.settings_push_no_subscribers }}</p>
|
||||
{% else %}
|
||||
<div class="table-wrap">
|
||||
<table class="table is-fullwidth is-striped is-narrow">
|
||||
<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 class="is-nowrap">{{ subscriber.last_updated }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="admin-subsection">
|
||||
<h3>Telegram</h3>
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="telegramToken">{{ t.settings_telegram_bot_token }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="telegramToken" class="input is-family-monospace" type="password" name="telegram_bot_token" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="field-help">{% if telegram_bot_token_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if telegram_bot_token_configured %}
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_telegram_bot_token" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_pricing_info }}</label>
|
||||
<div class="control">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">🛡️</span>
|
||||
<div>
|
||||
<h2>{{ t.settings_section_captcha }}</h2>
|
||||
<p>{{ t.settings_section_captcha_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="turnstileSiteKey">{{ t.settings_turnstile_site_key }}</label>
|
||||
<div class="control">
|
||||
<input id="turnstileSiteKey" class="input is-family-monospace" type="text" name="turnstile_site_key" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="turnstileSecret">{{ t.settings_turnstile_secret_key }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="turnstileSecret" class="input is-family-monospace" type="password" name="turnstile_secret_key" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="field-help">{% if turnstile_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if turnstile_secret_configured %}
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_turnstile_secret_key" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</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 %}">
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">🔐</span>
|
||||
<div>
|
||||
<h2>{{ t.settings_section_oidc }}</h2>
|
||||
<p>{{ t.settings_section_oidc_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<div class="admin-toggle-list">
|
||||
<label class="admin-toggle" for="passwordAuth">
|
||||
<input id="passwordAuth" type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span><strong>{{ t.settings_auth_password_enabled }}</strong></span>
|
||||
</label>
|
||||
<label class="admin-toggle" for="ssoAuth">
|
||||
<input id="ssoAuth" type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span><strong>{{ t.settings_auth_sso_enabled }}</strong></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="oidcIssuer">{{ t.settings_oidc_issuer_url }}</label>
|
||||
<div class="control">
|
||||
<input id="oidcIssuer" class="input" type="url" 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 admin-field">
|
||||
<label class="label" for="oidcClientId">{{ t.settings_oidc_client_id }}</label>
|
||||
<div class="control">
|
||||
<input id="oidcClientId" class="input is-family-monospace" type="text" name="oidc_client_id" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="oidcClientSecret">{{ t.settings_oidc_client_secret }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="oidcClientSecret" class="input is-family-monospace" type="password" name="oidc_client_secret" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="field-help">{% if oidc_client_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if oidc_client_secret_configured %}
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_oidc_client_secret" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="oidcGroups">{{ t.settings_oidc_allowed_groups }}</label>
|
||||
<div class="control">
|
||||
<input id="oidcGroups" 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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="sticky-actions">
|
||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var colors = [
|
||||
'rgba(124,108,255,.18)', 'rgba(255,82,135,.15)',
|
||||
'rgba(255,140,38,.18)', 'rgba(0,180,150,.15)',
|
||||
'rgba(77,166,255,.18)', 'rgba(255,179,64,.18)',
|
||||
'rgba(176,108,255,.16)', 'rgba(34,180,130,.16)'
|
||||
];
|
||||
var input = document.getElementById('seoKeywordsInput');
|
||||
var preview = document.getElementById('seoPreview');
|
||||
|
||||
function renderSeoPreview() {
|
||||
var words = input.value.split(',').map(function(value) { return value.trim(); }).filter(Boolean);
|
||||
preview.replaceChildren();
|
||||
preview.hidden = words.length === 0;
|
||||
words.forEach(function(word, index) {
|
||||
var tag = document.createElement('span');
|
||||
tag.textContent = word;
|
||||
tag.style.background = colors[index % colors.length];
|
||||
tag.style.borderRadius = '4px';
|
||||
tag.style.padding = '2px 6px';
|
||||
tag.style.margin = '2px';
|
||||
preview.appendChild(tag);
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', renderSeoPreview);
|
||||
renderSeoPreview();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,23 +4,20 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.nav_title }} — {{ t.setup_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-box { width: 100%; max-width: 400px; padding: 0 1rem; }
|
||||
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||
</head>
|
||||
<body>
|
||||
<body class="auth-shell">
|
||||
<div class="login-box">
|
||||
<div class="has-text-right mb-3">
|
||||
<a href="?lang={{ lang.other().code() }}" class="has-text-grey is-size-7">{{ lang.other().label() }}</a>
|
||||
<div class="auth-language">
|
||||
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="has-text-centered mb-4">
|
||||
<p class="is-size-3">🐾</p>
|
||||
<h1 class="is-size-4 has-text-weight-bold">{{ t.nav_title }}</h1>
|
||||
<p class="has-text-grey">{{ t.setup_title }}</p>
|
||||
<div class="auth-head">
|
||||
<span class="auth-icon">🐾</span>
|
||||
<h1>{{ t.nav_title }}</h1>
|
||||
<p>{{ t.setup_title }}</p>
|
||||
</div>
|
||||
<p class="has-text-grey has-text-centered is-size-7 mb-4">{{ t.setup_description }}</p>
|
||||
{% if let Some(err) = error.as_ref() %}
|
||||
|
||||
@@ -9,13 +9,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Add form -->
|
||||
<div class="form-card" style="margin-bottom:1.5rem;">
|
||||
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:0.75rem;">{{ t.testimonials_add_title }}</h2>
|
||||
<form method="post" action="/admin/testimonials/add" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
<section class="admin-section mb-5">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">💬</span>
|
||||
<div><h2>{{ t.testimonials_add_title }}</h2></div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<form method="post" action="/admin/testimonials/add" enctype="multipart/form-data" class="form-grid">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.testimonials_text }} *</label>
|
||||
<div class="control">
|
||||
<textarea class="input" name="text" rows="3" required style="min-height:80px;resize:vertical;"></textarea>
|
||||
<textarea class="textarea" name="text" rows="3" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -30,27 +34,28 @@
|
||||
<input class="input" type="file" name="image" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="button is-primary">{{ t.testimonials_add_button }}</button>
|
||||
<div class="admin-field-wide"><button type="submit" class="button is-primary">{{ t.testimonials_add_button }}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- List -->
|
||||
{% if testimonials.is_empty() %}
|
||||
<p style="color:#888;">{{ t.testimonials_empty }}</p>
|
||||
<p class="empty-state">{{ t.testimonials_empty }}</p>
|
||||
{% else %}
|
||||
{% for item in &testimonials %}
|
||||
<div class="item-card" id="card-{{ item.id.unwrap() }}">
|
||||
<!-- View mode -->
|
||||
<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;">
|
||||
<div class="testimonial-content">
|
||||
{% if let Some(image_url) = item.image_url.as_deref() %}
|
||||
<img src="{{ image_url }}" alt="" class="testimonial-avatar">
|
||||
{% endif %}
|
||||
<div>
|
||||
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
|
||||
<div class="testimonial-text">{{ item.text }}</div>
|
||||
{% if let Some(note) = item.author_note.as_deref() %}
|
||||
<div style="font-size:0.8rem;color:#888;margin-top:0.2rem;">{{ note }}</div>
|
||||
<div class="testimonial-note">{{ note }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,7 +84,7 @@
|
||||
<div class="field">
|
||||
<label class="label">{{ t.testimonials_text }}</label>
|
||||
<div class="control">
|
||||
<textarea class="input" name="text" rows="3" style="min-height:70px;resize:vertical;">{{ item.text }}</textarea>
|
||||
<textarea class="textarea" name="text" rows="3">{{ item.text }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -90,10 +95,12 @@
|
||||
</div>
|
||||
{% 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;">
|
||||
<label style="font-size:0.85rem;cursor:pointer;color:#888;">
|
||||
<input type="checkbox" name="remove_image" value="1"> {{ t.testimonials_remove_image }}
|
||||
<div class="testimonial-content mb-2">
|
||||
{% if let Some(image_url) = item.image_url.as_deref() %}<img src="{{ image_url }}" alt="" class="testimonial-avatar">{% endif %}
|
||||
<label class="admin-check admin-check-danger">
|
||||
<input type="checkbox" name="remove_image" value="1">
|
||||
<span class="admin-check-box">✓</span>
|
||||
<span>{{ t.testimonials_remove_image }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +111,7 @@
|
||||
<input class="input" type="file" name="image" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<div class="action-row">
|
||||
<button type="submit" class="button btn-sm is-primary">{{ t.testimonials_save }}</button>
|
||||
<button type="button" class="button btn-sm is-light" onclick="toggleEdit({{ item.id.unwrap() }})">✕</button>
|
||||
</div>
|
||||
|
||||
+49
-32
@@ -11,12 +11,34 @@
|
||||
{% for user in &users %}
|
||||
<div class="item-card">
|
||||
<div class="item-card-header">
|
||||
<span class="name">{{ user.login }}{% if let Some(dn) = user.display_name.as_deref() %} <span style="font-weight:400;color:#888;">— {{ dn }}</span>{% endif %}</span>
|
||||
<span class="name">{{ user.login }}{% if let Some(dn) = user.display_name.as_deref() %} <span class="muted-detail">— {{ dn }}</span>{% endif %}</span>
|
||||
<span class="badge badge-{{ user.status }}">{{ t.client_status(&user.status) }}</span>
|
||||
</div>
|
||||
<div class="item-card-meta">
|
||||
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
|
||||
</div>
|
||||
{% if user.status == "active" %}
|
||||
<form method="post" action="/admin/users/{{ user.id }}/telegram" class="inline-admin-form">
|
||||
<div class="inline-admin-grid">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_telegram_chat_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input is-small" type="text" name="telegram_chat_id" placeholder="123456789" value="{{ user.telegram_chat_id.as_deref().unwrap_or_default() }}">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="admin-toggle admin-toggle-compact">
|
||||
<input type="checkbox" name="telegram_notifications" value="true" {% if user.telegram_notifications == Some(true) %}checked{% endif %}>
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span><strong>{{ t.users_telegram_enabled }}</strong></span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="button is-small is-info is-outlined">💾</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="item-card-actions">
|
||||
{% if user.status == "active" %}
|
||||
<form method="post" action="/admin/users/{{ user.id }}/archive">
|
||||
@@ -31,41 +53,36 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="form-card" style="margin-top:1.5rem;">
|
||||
<h2 class="is-size-5 has-text-weight-bold mb-3">{{ t.users_add_title }}</h2>
|
||||
<section class="admin-section mt-5">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">➕</span>
|
||||
<div><h2>{{ t.users_add_title }}</h2></div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
{% if let Some(err) = error.as_ref() %}
|
||||
<div class="notification is-danger is-light">{{ err }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/users/add">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_login }}</label>
|
||||
<div class="control"><input class="input" type="text" name="login" required></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_display_name }}</label>
|
||||
<div class="control"><input class="input" type="text" name="display_name"></div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/users/add" class="form-grid">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_login }}</label>
|
||||
<div class="control"><input class="input" type="text" name="login" required></div>
|
||||
</div>
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_password }}</label>
|
||||
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_password_confirm }}</label>
|
||||
<div class="control"><input class="input" type="password" name="password_confirm" required minlength="4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_display_name }}</label>
|
||||
<div class="control"><input class="input" type="text" name="display_name"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_password }}</label>
|
||||
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_password_confirm }}</label>
|
||||
<div class="control"><input class="input" type="password" name="password_confirm" required minlength="4"></div>
|
||||
</div>
|
||||
<div class="admin-field-wide">
|
||||
<button type="submit" class="button is-primary">{{ t.users_add_button }}</button>
|
||||
</div>
|
||||
<button type="submit" class="button is-primary">{{ t.users_add_button }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
<meta charset="UTF-8">
|
||||
<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 %}
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
@@ -12,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;
|
||||
@@ -46,14 +57,26 @@
|
||||
.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 {
|
||||
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
|
||||
font-size: 0.85rem; font-family: inherit; resize: vertical; min-height: 50px;
|
||||
background-color: #fff; color: #333;
|
||||
}
|
||||
.feedback-form textarea:focus { outline: none; border-color: #7c6cff; }
|
||||
.feedback-form button {
|
||||
@@ -96,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>
|
||||
@@ -107,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">
|
||||
@@ -115,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 }}">
|
||||
@@ -141,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 %}
|
||||
@@ -163,6 +223,9 @@
|
||||
</div>
|
||||
<form class="feedback-form" id="fb-form-{{ pv.visit.id }}" style="display:none;" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
|
||||
<textarea name="feedback" required>{{ fb }}</textarea>
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
|
||||
{% endif %}
|
||||
<div style="display:flex;gap:0.4rem;">
|
||||
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
||||
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})">✕</button>
|
||||
@@ -171,12 +234,22 @@
|
||||
{% else %}
|
||||
<form class="feedback-form" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
|
||||
<textarea name="feedback" placeholder="{{ t.portal_feedback_placeholder }}" required></textarea>
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
|
||||
{% endif %}
|
||||
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% 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 -->
|
||||
@@ -186,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';
|
||||
@@ -203,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>
|
||||
|
||||
+62
-61
@@ -5,11 +5,30 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{{ t.landing_meta_description }}">
|
||||
<title>{{ t.nav_title }} — {{ t.landing_hero_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
|
||||
<!-- Canonical & Hreflang -->
|
||||
<link rel="canonical" href="{{ site_domain }}/?lang={{ lang.code() }}">
|
||||
<link rel="alternate" hreflang="ru" href="{{ site_domain }}/?lang=ru">
|
||||
<link rel="alternate" hreflang="en" href="{{ site_domain }}/?lang=en">
|
||||
<link rel="alternate" hreflang="x-default" href="{{ site_domain }}/">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||
<meta property="og:description" content="{{ t.landing_meta_description }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ site_domain }}/?lang={{ lang.code() }}">
|
||||
<meta property="og:locale" content="{% if lang.code() == "ru" %}ru_RU{% else %}en_US{% endif %}">
|
||||
<meta property="og:site_name" content="{{ t.nav_title }}">
|
||||
|
||||
{% if !seo_keywords.is_empty() %}
|
||||
<meta name="keywords" content="{{ seo_keywords }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||
<meta name="twitter:description" content="{{ t.landing_meta_description }}">
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
@@ -19,10 +38,22 @@
|
||||
"name": "{{ t.nav_title }}",
|
||||
"description": "{{ t.landing_meta_description }}",
|
||||
"serviceType": "Pet Sitting",
|
||||
"@id": "#business"
|
||||
"url": "{{ site_domain }}/",
|
||||
"@id": "#business"{% if review_count > 0 %},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5",
|
||||
"bestRating": "5",
|
||||
"ratingCount": "{{ review_count }}",
|
||||
"reviewCount": "{{ review_count }}"
|
||||
}{% endif %}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
/* ── Reset & Base ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -96,50 +127,24 @@
|
||||
box-shadow: 0 4px 20px rgba(124,108,255,0.35);
|
||||
}
|
||||
.hero-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(124,108,255,0.45); }
|
||||
.hero { position: relative; }
|
||||
.hero-blob {
|
||||
position: absolute;
|
||||
.hero { position: relative; overflow: clip; }
|
||||
.hero-photo {
|
||||
position: absolute; z-index: 0;
|
||||
width: 420px; height: 420px;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.hero-blob-br {
|
||||
width: 360px; height: 400px;
|
||||
right: -100px; bottom: -110px;
|
||||
background-color: #ffe0ec;
|
||||
background-image: radial-gradient(ellipse at 40% 40%, #ffd6e8 0%, #f8c4d8 60%, #eab0d0 100%);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-blob-tl {
|
||||
width: 240px; height: 270px;
|
||||
left: -65px; top: -15px;
|
||||
background-color: #d8ecff;
|
||||
background-image: radial-gradient(ellipse at 60% 60%, #d0e8ff 0%, #b8d8f8 60%, #a4c8f0 100%);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-blob-bl {
|
||||
width: 290px; height: 330px;
|
||||
left: -80px; bottom: -90px;
|
||||
background-color: #d4f0d8;
|
||||
background-image: radial-gradient(ellipse at 55% 45%, #ddf5e0 0%, #c4e8ca 60%, #b0dbb8 100%);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-blob svg {
|
||||
position: absolute; opacity: 0.3;
|
||||
}
|
||||
.hero-blob img {
|
||||
width: 100%; height: 100%; object-fit: contain;
|
||||
border: 20px solid #ffe0ec;
|
||||
object-fit: cover;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hero-photo-tr { right: -100px; top: -100px; }
|
||||
.hero-photo-bl { left: -100px; bottom: -150px; }
|
||||
.hero-content { position: relative; z-index: 1; }
|
||||
.section-alt { position: relative; z-index: 1; }
|
||||
@media (max-width: 600px) {
|
||||
.hero-blob-br { width: 200px; height: 240px; right: -55px; bottom: -65px; }
|
||||
.hero-blob-bl { width: 180px; height: 210px; left: -50px; bottom: -55px; }
|
||||
.hero-blob-tl { width: 140px; height: 160px; left: -40px; top: -10px; }
|
||||
.hero-photo { width: 200px; height: 200px; border-width: 12px; }
|
||||
.hero-photo-tr { right: -50px; top: -50px; }
|
||||
.hero-photo-bl { left: -50px; bottom: -70px; }
|
||||
.hero-cta { display: block; width: fit-content; margin-left: auto; margin-right: 0; }
|
||||
}
|
||||
.hero-emoji { font-size: 4rem; margin-bottom: 1rem; display: block; }
|
||||
.hero-desc {
|
||||
@@ -230,7 +235,7 @@
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 36px rgba(124,108,255,0.12);
|
||||
}
|
||||
.service-icon { font-size: 2.5rem; margin-bottom: 0.75rem; display: block; }
|
||||
.service-icon { font-size: 2.5rem; margin-bottom: 0.75rem; display: block; text-align: center; }
|
||||
.service-card h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 0.5rem; color: #2d2b55; }
|
||||
.service-card p { color: #5a5680; font-size: 0.95rem; line-height: 1.6; }
|
||||
|
||||
@@ -304,10 +309,14 @@
|
||||
border-top: 1px solid rgba(180,170,220,0.15);
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.seo-keywords {
|
||||
font-size: 0.72rem; color: #aaa; line-height: 2;
|
||||
max-width: 800px; margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 600px) {
|
||||
.hero { padding: 6.5rem 1rem 3rem; }
|
||||
.hero { padding: 6.5rem 1rem 10rem; }
|
||||
.section { padding: 3rem 1rem; }
|
||||
.form-section { padding: 3rem 1rem; }
|
||||
.form-wrapper { padding: 1.75rem 1.25rem; }
|
||||
@@ -335,22 +344,8 @@
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="hero-blob hero-blob-tl">
|
||||
<svg viewBox="0 0 60 70" width="50" height="58" style="left:15%;top:18%;transform:rotate(-15deg);"><ellipse cx="30" cy="46" rx="16" ry="18" fill="#8cb8e8" opacity="0.55"/><ellipse cx="14" cy="22" rx="8" ry="10" fill="#8cb8e8" opacity="0.55" transform="rotate(-10 14 22)"/><ellipse cx="46" cy="22" rx="8" ry="10" fill="#8cb8e8" opacity="0.55" transform="rotate(10 46 22)"/><ellipse cx="24" cy="8" rx="6" ry="8" fill="#8cb8e8" opacity="0.55" transform="rotate(-5 24 8)"/><ellipse cx="38" cy="8" rx="6" ry="8" fill="#8cb8e8" opacity="0.55" transform="rotate(5 38 8)"/></svg>
|
||||
<svg viewBox="0 0 60 55" width="32" height="30" style="right:20%;bottom:22%;transform:rotate(12deg);"><path d="M30 50 C10 35 0 22 0 14 C0 5 7 0 15 0 C21 0 26 3 30 9 C34 3 39 0 45 0 C53 0 60 5 60 14 C60 22 50 35 30 50Z" fill="#7baad4" opacity="0.5"/></svg>
|
||||
<svg viewBox="0 0 80 40" width="44" height="22" style="left:50%;top:60%;transform:rotate(8deg);"><path d="M60 20 Q48 2 28 8 Q10 14 10 20 Q10 26 28 32 Q48 38 60 20Z" fill="#9ac4e8" opacity="0.3"/><path d="M60 20 L74 6 L74 34 Z" fill="#9ac4e8" opacity="0.3"/></svg>
|
||||
</div>
|
||||
<div class="hero-blob hero-blob-bl">
|
||||
<svg viewBox="0 0 60 70" width="45" height="52" style="right:18%;top:15%;transform:rotate(20deg);"><path d="M30 65 C30 65 10 45 10 25 C10 10 25 2 30 2 C35 2 50 10 50 25 C50 45 30 65 30 65Z" fill="#8cc49a" opacity="0.35"/><path d="M30 60 L30 20 M30 35 Q20 30 15 25 M30 45 Q40 40 45 35" fill="none" stroke="#7ab88a" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<svg viewBox="0 0 80 40" width="40" height="20" style="left:30%;bottom:25%;transform:rotate(-12deg);"><path d="M60 20 Q48 2 28 8 Q10 14 10 20 Q10 26 28 32 Q48 38 60 20Z" fill="#90c8a0" opacity="0.3"/><path d="M60 20 L74 6 L74 34 Z" fill="#90c8a0" opacity="0.3"/></svg>
|
||||
<svg viewBox="0 0 60 55" width="30" height="28" style="right:30%;bottom:40%;transform:rotate(15deg);"><path d="M30 50 C10 35 0 22 0 14 C0 5 7 0 15 0 C21 0 26 3 30 9 C34 3 39 0 45 0 C53 0 60 5 60 14 C60 22 50 35 30 50Z" fill="#7cb88c" opacity="0.5"/></svg>
|
||||
</div>
|
||||
<div class="hero-blob hero-blob-br">
|
||||
<svg viewBox="0 0 80 80" width="60" height="60" style="left:12%;top:15%;transform:rotate(10deg);"><path d="M20 15 L10 2 L18 18 M60 15 L70 2 L62 18 M40 70 C18 70 8 52 8 38 C8 20 22 8 40 8 C58 8 72 20 72 38 C72 52 62 70 40 70Z" fill="none" stroke="#e89cb8" stroke-width="3" stroke-linecap="round"/><circle cx="28" cy="36" r="3.5" fill="#e89cb8"/><circle cx="52" cy="36" r="3.5" fill="#e89cb8"/><ellipse cx="40" cy="48" rx="4" ry="2.5" fill="#e89cb8"/></svg>
|
||||
<svg viewBox="0 0 60 70" width="40" height="47" style="right:15%;top:50%;transform:rotate(-20deg);"><ellipse cx="30" cy="46" rx="16" ry="18" fill="#e8a0b8" opacity="0.55"/><ellipse cx="14" cy="22" rx="8" ry="10" fill="#e8a0b8" opacity="0.55" transform="rotate(-10 14 22)"/><ellipse cx="46" cy="22" rx="8" ry="10" fill="#e8a0b8" opacity="0.55" transform="rotate(10 46 22)"/><ellipse cx="24" cy="8" rx="6" ry="8" fill="#e8a0b8" opacity="0.55" transform="rotate(-5 24 8)"/><ellipse cx="38" cy="8" rx="6" ry="8" fill="#e8a0b8" opacity="0.55" transform="rotate(5 38 8)"/></svg>
|
||||
<svg viewBox="0 0 60 55" width="36" height="33" style="left:55%;bottom:18%;transform:rotate(-8deg);"><path d="M30 50 C10 35 0 22 0 14 C0 5 7 0 15 0 C21 0 26 3 30 9 C34 3 39 0 45 0 C53 0 60 5 60 14 C60 22 50 35 30 50Z" fill="#d48aaa" opacity="0.5"/></svg>
|
||||
<svg viewBox="0 0 60 70" width="30" height="35" style="left:20%;bottom:30%;transform:rotate(25deg);"><path d="M30 65 C30 65 10 45 10 25 C10 10 25 2 30 2 C35 2 50 10 50 25 C50 45 30 65 30 65Z" fill="#e8b0c4" opacity="0.35"/><path d="M30 60 L30 20 M30 35 Q20 30 15 25 M30 45 Q40 40 45 35" fill="none" stroke="#d49ab0" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<img class="hero-photo hero-photo-tr" src="/static/cat_up_right.png" alt="">
|
||||
<img class="hero-photo hero-photo-bl" src="/static/cat_bottom_left.png" alt="">
|
||||
<div class="hero-content">
|
||||
<span class="hero-emoji" role="img" aria-label="pets">🐱🐹🦎</span>
|
||||
<h1>{{ t.landing_hero_title }}</h1>
|
||||
@@ -431,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>
|
||||
@@ -473,6 +468,9 @@
|
||||
<input type="checkbox" id="consent" name="consent" required style="margin-top:0.2rem;width:auto;flex-shrink:0;">
|
||||
<label for="consent" style="font-size:0.82rem;font-weight:400;color:#7a7599;cursor:pointer;display:inline;">{{ t.landing_form_consent }}</label>
|
||||
</div>
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-size="compact" style="margin-bottom:1.25rem;"></div>
|
||||
{% endif %}
|
||||
<button type="submit" class="form-submit">{{ t.landing_form_submit }}</button>
|
||||
</form>
|
||||
{% if !contact_info.is_empty() %}
|
||||
@@ -486,6 +484,9 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="site-footer">
|
||||
{% if !seo_keywords.is_empty() %}
|
||||
<p class="seo-keywords" id="seoKeywords">{{ seo_keywords }}</p>
|
||||
{% endif %}
|
||||
<p>{{ t.landing_footer_text }}</p>
|
||||
<p style="margin-top:0.4rem;">© 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
|
||||
</footer>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.nav_title }} — {{ t.landing_thank_you_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
|
||||
Reference in New Issue
Block a user