Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46351351fc | ||
|
|
6449d8b59a | ||
|
|
9587622285 | ||
|
|
866118497f | ||
|
|
95154c56e7 | ||
|
|
8168d33440 | ||
|
|
5fd26c816a | ||
|
|
7f49b1f314 | ||
|
|
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 | ||
|
|
bfd0aec56f | ||
|
|
68d578b29d | ||
|
|
21331b75a8 | ||
|
|
6395e36c62 | ||
|
|
87fb8c744d | ||
|
|
357a2ed423 | ||
|
|
434ed7a376 |
No files matched your search
@@ -2,9 +2,9 @@ name: Build and Publish
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
#branches:
|
||||||
- master
|
# - master
|
||||||
- main
|
# - main
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
/data
|
/data
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
/uploads
|
/uploads
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
## Tech Stack
|
||||||
|
|
||||||
- **Language:** Rust (edition 2024)
|
- **Language:** Rust (edition 2024)
|
||||||
- **Web framework:** [Cot](https://github.com/cot-rs/cot) — Rust web framework (Django-like), local path `../cot/cot`
|
- **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`
|
- **Database:** PostgreSQL (via Cot ORM)
|
||||||
- **Notifications:** Telegram Bot API
|
- **Notifications:** Telegram Bot API
|
||||||
|
|
||||||
## Build & Run
|
## Build & Run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build # build
|
cargo build # build
|
||||||
cargo run # run dev server at http://127.0.0.1:8000
|
cargo run # run dev server at http://127.0.0.1:8000
|
||||||
cargo test # run all tests
|
cargo test # run all tests
|
||||||
cargo test <name> # run a single test by name
|
cargo test <name> # run a single test by name
|
||||||
cargo clippy # lint
|
cargo clippy # lint
|
||||||
cargo fmt --check # check formatting
|
cargo fmt --check # check formatting
|
||||||
cot migration make # generate migrations from model changes (requires cot-cli)
|
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
|
## 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/main.rs` - project/app setup, router, config
|
||||||
- `src/models.rs` — all database models (Lead, Client, Visit, Media, User, Setting)
|
- `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.rs` - migration registry
|
||||||
- `src/migrations/` — migration files (auto-generated)
|
- `src/migrations/` - migration files
|
||||||
|
|
||||||
## Database Design Principles
|
## Database Design Principles
|
||||||
|
|
||||||
@@ -46,9 +52,9 @@ Monolithic Cot web app with a single SQLite database.
|
|||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
- **Lead** (`new`/`in_progress`/`converted`/`rejected`) — public form submission; links to Client when converted
|
- **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
|
- **Client** (`active`/`archived`) - confirmed client with `media_token` for public media page
|
||||||
- **Visit** (`scheduled`/`completed`/`cancelled`) — pet sitting session, belongs to Client
|
- **Visit** (`scheduled`/`completed`/`cancelled`/`deleted`) - pet sitting session, belongs to Client and User
|
||||||
- **Media** (`active`/`archived`) — photo/video, belongs to Client, optionally to Visit
|
- **Media** (`active`/`archived`) - photo/video, belongs to Client, optionally to Visit
|
||||||
- **User** (`active`/`archived`) — admin accounts (supports multiple admins)
|
- **User** (`active`/`archived`) - admin accounts (supports multiple admins)
|
||||||
- **Setting** — global key-value config (telegram_bot_token, telegram_chat_id, etc.)
|
- **Setting** - global key-value config (telegram_bot_token, telegram_chat_id, etc.)
|
||||||
Generated
+1683
-127
File diff suppressed because it is too large.
Load diff
+14
-3
@@ -1,11 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.3"
|
version = "1.0.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
default-run = "web-petting"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cot = { version = "0.6.0", features = ["sqlite"] }
|
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
chrono-tz = "0.10"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_html_form = "0.4"
|
serde_html_form = "0.4"
|
||||||
password-auth = "1"
|
password-auth = "1"
|
||||||
@@ -13,5 +15,14 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
multer = "3"
|
multer = "3"
|
||||||
futures = "0.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"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
urlencoding = "2"
|
||||||
|
idna = "1"
|
||||||
|
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/*
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY Cargo.toml Cargo.lock* ./
|
COPY Cargo.toml Cargo.lock* ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY templates ./templates
|
COPY templates ./templates
|
||||||
RUN cargo build --release
|
RUN cargo build --release --bins
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
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
|
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/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
|
EXPOSE 3000
|
||||||
CMD ["web-petting"]
|
CMD ["web-petting"]
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
One-off data migration from the old web-petting SQLite database to PostgreSQL.
|
||||||
|
|
||||||
|
The script intentionally lives outside the Rust application and does not affect
|
||||||
|
the app dependency graph. It migrates user-owned application data only and skips
|
||||||
|
Cot technical tables such as cot__migrations and cot__session.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
1. Run the new app migrations against PostgreSQL first, so the target tables
|
||||||
|
already exist.
|
||||||
|
2. Install one PostgreSQL Python driver in the environment that runs this
|
||||||
|
script:
|
||||||
|
python -m pip install "psycopg[binary]"
|
||||||
|
psycopg2 is also supported if it is already installed.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/migrate_sqlite_to_postgres.py --sqlite db.sqlite3 --postgres-url postgresql://...
|
||||||
|
python scripts/migrate_sqlite_to_postgres.py --dry-run
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
Transform = Callable[[Any], Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TableSpec:
|
||||||
|
name: str
|
||||||
|
columns: tuple[str, ...]
|
||||||
|
transforms: dict[str, Transform] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def optional_bool(value: Any) -> bool | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value in (0, "0", False):
|
||||||
|
return False
|
||||||
|
if value in (1, "1", True):
|
||||||
|
return True
|
||||||
|
raise ValueError(f"cannot convert {value!r} to bool")
|
||||||
|
|
||||||
|
|
||||||
|
TABLES: tuple[TableSpec, ...] = (
|
||||||
|
TableSpec(
|
||||||
|
"web_petting__user",
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
"login",
|
||||||
|
"password_hash",
|
||||||
|
"display_name",
|
||||||
|
"telegram_chat_id",
|
||||||
|
"telegram_notifications",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
),
|
||||||
|
transforms={"telegram_notifications": optional_bool},
|
||||||
|
),
|
||||||
|
TableSpec(
|
||||||
|
"web_petting__setting",
|
||||||
|
("id", "key", "value", "updated_at"),
|
||||||
|
),
|
||||||
|
TableSpec(
|
||||||
|
"web_petting__client",
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
"address",
|
||||||
|
"notes",
|
||||||
|
"media_token",
|
||||||
|
"color",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TableSpec(
|
||||||
|
"web_petting__testimonial",
|
||||||
|
("id", "text", "author_note", "image_path", "status", "sort_order", "created_at"),
|
||||||
|
),
|
||||||
|
TableSpec(
|
||||||
|
"web_petting__visit",
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
"client_id",
|
||||||
|
"user_id",
|
||||||
|
"visit_date",
|
||||||
|
"time_start",
|
||||||
|
"time_end",
|
||||||
|
"notes",
|
||||||
|
"public_notes",
|
||||||
|
"client_feedback",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TableSpec(
|
||||||
|
"web_petting__media",
|
||||||
|
("id", "client_id", "visit_id", "file_path", "file_type", "caption", "status", "created_at"),
|
||||||
|
),
|
||||||
|
TableSpec(
|
||||||
|
"web_petting__lead",
|
||||||
|
("id", "name", "phone", "email", "comment", "status", "client_id", "created_at", "updated_at"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FK_CHECKS: tuple[tuple[str, str, str, str], ...] = (
|
||||||
|
("web_petting__lead", "client_id", "web_petting__client", "id"),
|
||||||
|
("web_petting__visit", "client_id", "web_petting__client", "id"),
|
||||||
|
("web_petting__visit", "user_id", "web_petting__user", "id"),
|
||||||
|
("web_petting__media", "client_id", "web_petting__client", "id"),
|
||||||
|
("web_petting__media", "visit_id", "web_petting__visit", "id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def quote_ident(identifier: str) -> str:
|
||||||
|
return '"' + identifier.replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def pg_table_name(table: str, schema: str | None) -> str:
|
||||||
|
if schema:
|
||||||
|
return f"{quote_ident(schema)}.{quote_ident(table)}"
|
||||||
|
return quote_ident(table)
|
||||||
|
|
||||||
|
|
||||||
|
def sequence_relation_name(table: str, schema: str | None) -> str:
|
||||||
|
if schema:
|
||||||
|
return f"{schema}.{table}"
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
"select 1 from sqlite_schema where type = 'table' and name = ?",
|
||||||
|
(table,),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||||
|
return {row["name"] for row in conn.execute(f"pragma table_info({quote_ident(table)})")}
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_count(conn: sqlite3.Connection, table: str) -> int:
|
||||||
|
return int(conn.execute(f"select count(*) from {quote_ident(table)}").fetchone()[0])
|
||||||
|
|
||||||
|
|
||||||
|
def read_rows(conn: sqlite3.Connection, spec: TableSpec) -> list[tuple[Any, ...]]:
|
||||||
|
columns_sql = ", ".join(quote_ident(column) for column in spec.columns)
|
||||||
|
rows: list[tuple[Any, ...]] = []
|
||||||
|
for row in conn.execute(f"select {columns_sql} from {quote_ident(spec.name)} order by id"):
|
||||||
|
values = []
|
||||||
|
for column in spec.columns:
|
||||||
|
value = row[column]
|
||||||
|
if spec.transforms and column in spec.transforms:
|
||||||
|
value = spec.transforms[column](value)
|
||||||
|
values.append(value)
|
||||||
|
rows.append(tuple(values))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sqlite(conn: sqlite3.Connection) -> None:
|
||||||
|
for spec in TABLES:
|
||||||
|
if not sqlite_table_exists(conn, spec.name):
|
||||||
|
raise RuntimeError(f"SQLite table is missing: {spec.name}")
|
||||||
|
existing_columns = sqlite_columns(conn, spec.name)
|
||||||
|
missing_columns = [column for column in spec.columns if column not in existing_columns]
|
||||||
|
if missing_columns:
|
||||||
|
joined = ", ".join(missing_columns)
|
||||||
|
raise RuntimeError(f"SQLite table {spec.name} is missing columns: {joined}")
|
||||||
|
|
||||||
|
for source_table, source_column, target_table, target_column in FK_CHECKS:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
select s.id, s.{quote_ident(source_column)}
|
||||||
|
from {quote_ident(source_table)} s
|
||||||
|
left join {quote_ident(target_table)} t
|
||||||
|
on s.{quote_ident(source_column)} = t.{quote_ident(target_column)}
|
||||||
|
where s.{quote_ident(source_column)} is not null
|
||||||
|
and t.{quote_ident(target_column)} is null
|
||||||
|
order by s.id
|
||||||
|
limit 20
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
if rows:
|
||||||
|
examples = ", ".join(
|
||||||
|
f"id={row[0]} {source_column}={row[1]}" for row in rows
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"SQLite FK check failed: {source_table}.{source_column} -> "
|
||||||
|
f"{target_table}.{target_column}; examples: {examples}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_postgres_driver() -> Any:
|
||||||
|
try:
|
||||||
|
import psycopg # type: ignore
|
||||||
|
|
||||||
|
return psycopg
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psycopg2 # type: ignore
|
||||||
|
|
||||||
|
return psycopg2
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No PostgreSQL Python driver found. Install one with: "
|
||||||
|
'python -m pip install "psycopg[binary]"'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def connect_postgres(postgres_url: str) -> Any:
|
||||||
|
driver = import_postgres_driver()
|
||||||
|
return driver.connect(postgres_url)
|
||||||
|
|
||||||
|
|
||||||
|
def postgres_table_exists(cur: Any, table: str, schema: str | None) -> bool:
|
||||||
|
if schema:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
select 1
|
||||||
|
from information_schema.tables
|
||||||
|
where table_schema = %s and table_name = %s
|
||||||
|
""",
|
||||||
|
(schema, table),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute("select to_regclass(%s)", (table,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
return bool(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def postgres_count(cur: Any, table: str, schema: str | None) -> int:
|
||||||
|
cur.execute(f"select count(*) from {pg_table_name(table, schema)}")
|
||||||
|
return int(cur.fetchone()[0])
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_postgres_ready(cur: Any, schema: str | None, allow_existing: bool) -> None:
|
||||||
|
for spec in TABLES:
|
||||||
|
if not postgres_table_exists(cur, spec.name, schema):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"PostgreSQL table is missing: {spec.name}. "
|
||||||
|
"Run the new app migrations before this script."
|
||||||
|
)
|
||||||
|
count = postgres_count(cur, spec.name, schema)
|
||||||
|
if count and not allow_existing:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"PostgreSQL table {spec.name} already contains {count} rows. "
|
||||||
|
"Use --truncate-user-tables to replace user data."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_user_tables(cur: Any, schema: str | None) -> None:
|
||||||
|
tables = ", ".join(pg_table_name(spec.name, schema) for spec in TABLES)
|
||||||
|
cur.execute(f"truncate table {tables} restart identity")
|
||||||
|
|
||||||
|
|
||||||
|
def insert_table(cur: Any, spec: TableSpec, rows: list[tuple[Any, ...]], schema: str | None) -> None:
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
columns_sql = ", ".join(quote_ident(column) for column in spec.columns)
|
||||||
|
placeholders = ", ".join(["%s"] * len(spec.columns))
|
||||||
|
sql = f"insert into {pg_table_name(spec.name, schema)} ({columns_sql}) values ({placeholders})"
|
||||||
|
cur.executemany(sql, rows)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_sequence(cur: Any, table: str, schema: str | None) -> None:
|
||||||
|
relation = sequence_relation_name(table, schema)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
select setval(
|
||||||
|
pg_get_serial_sequence(%s, 'id'),
|
||||||
|
greatest(coalesce((select max(id) from %s), 0), 1),
|
||||||
|
coalesce((select max(id) from %s), 0) > 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
% ("%s", pg_table_name(table, schema), pg_table_name(table, schema)),
|
||||||
|
(relation,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(sqlite_path: Path, postgres_url: str, schema: str | None, truncate: bool) -> None:
|
||||||
|
sqlite_conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
||||||
|
sqlite_conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
validate_sqlite(sqlite_conn)
|
||||||
|
source_rows = {spec.name: read_rows(sqlite_conn, spec) for spec in TABLES}
|
||||||
|
finally:
|
||||||
|
sqlite_conn.close()
|
||||||
|
|
||||||
|
pg_conn = connect_postgres(postgres_url)
|
||||||
|
try:
|
||||||
|
with pg_conn:
|
||||||
|
with pg_conn.cursor() as cur:
|
||||||
|
ensure_postgres_ready(cur, schema, allow_existing=truncate)
|
||||||
|
if truncate:
|
||||||
|
truncate_user_tables(cur, schema)
|
||||||
|
for spec in TABLES:
|
||||||
|
insert_table(cur, spec, source_rows[spec.name], schema)
|
||||||
|
print(f"inserted {len(source_rows[spec.name])} rows into {spec.name}")
|
||||||
|
for spec in TABLES:
|
||||||
|
reset_sequence(cur, spec.name, schema)
|
||||||
|
for spec in TABLES:
|
||||||
|
target_count = postgres_count(cur, spec.name, schema)
|
||||||
|
source_count = len(source_rows[spec.name])
|
||||||
|
if target_count != source_count:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"count mismatch for {spec.name}: source={source_count}, target={target_count}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
pg_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def dry_run(sqlite_path: Path) -> None:
|
||||||
|
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
validate_sqlite(conn)
|
||||||
|
print("SQLite source is valid.")
|
||||||
|
for spec in TABLES:
|
||||||
|
print(f"{spec.name}: {sqlite_count(conn, spec.name)} rows")
|
||||||
|
print("Cot technical tables are intentionally skipped.")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Migrate web-petting user data from SQLite to PostgreSQL.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sqlite",
|
||||||
|
default="db.sqlite3",
|
||||||
|
type=Path,
|
||||||
|
help="Path to the downloaded SQLite database (default: db.sqlite3).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--postgres-url",
|
||||||
|
default=os.environ.get("WEB_PETTING_DATABASE_URL") or os.environ.get("DATABASE_URL"),
|
||||||
|
help="PostgreSQL URL. Defaults to WEB_PETTING_DATABASE_URL or DATABASE_URL.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--schema",
|
||||||
|
default="public",
|
||||||
|
help="Target PostgreSQL schema (default: public). Use an empty string for current search_path.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--truncate-user-tables",
|
||||||
|
action="store_true",
|
||||||
|
help="Delete existing rows from migrated application tables before inserting.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Validate and count SQLite rows without connecting to PostgreSQL.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
sqlite_path = args.sqlite
|
||||||
|
if not sqlite_path.exists():
|
||||||
|
print(f"SQLite file not found: {sqlite_path}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
schema = args.schema or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.dry_run:
|
||||||
|
dry_run(sqlite_path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.postgres_url:
|
||||||
|
print(
|
||||||
|
"PostgreSQL URL is required. Pass --postgres-url or set WEB_PETTING_DATABASE_URL.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
migrate(sqlite_path, args.postgres_url, schema, args.truncate_user_tables)
|
||||||
|
print("Migration complete.")
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Migration failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+1659
-130
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,528 @@
|
|||||||
|
//! Visit analytics: recording anonymous events and aggregating them for the
|
||||||
|
//! admin statistics dashboard.
|
||||||
|
//!
|
||||||
|
//! Events are stored append-only in [`AnalyticsEvent`]. Three things are
|
||||||
|
//! tracked:
|
||||||
|
//! - `landing_view` — a (non-bot) visit to the public landing page,
|
||||||
|
//! - `portal_open` — a client opening their private portal page,
|
||||||
|
//! - `media_view` — a client opening a full-size photo/video from the archive.
|
||||||
|
//!
|
||||||
|
//! Uniqueness is counted per anonymous first-party cookie (`vid`), never by IP.
|
||||||
|
|
||||||
|
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime};
|
||||||
|
use cot::db::query;
|
||||||
|
use cot::db::{Auto, Database, ForeignKey, Model};
|
||||||
|
use cot::request::Request;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::models::{AnalyticsEvent, EventType};
|
||||||
|
|
||||||
|
/// Name of the anonymous first-party visitor cookie.
|
||||||
|
pub const VISITOR_COOKIE: &str = "vid";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recording
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Technical request context stored alongside an event (never includes IP).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RequestMeta {
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
pub referer: Option<String>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestMeta {
|
||||||
|
/// Extracts User-Agent, Referer and path+query from the request.
|
||||||
|
pub fn from_request(request: &Request) -> Self {
|
||||||
|
let header = |name: &str| {
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.get(name)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::to_string)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
};
|
||||||
|
let path = request
|
||||||
|
.uri()
|
||||||
|
.path_and_query()
|
||||||
|
.map(|pq| pq.as_str().to_string());
|
||||||
|
Self {
|
||||||
|
user_agent: header("user-agent"),
|
||||||
|
referer: header("referer"),
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records an analytics event. Failures are logged but never surfaced to the
|
||||||
|
/// caller, so instrumentation can never break a user-facing request.
|
||||||
|
pub async fn record(
|
||||||
|
db: &Database,
|
||||||
|
event_type: EventType,
|
||||||
|
client_id: Option<i64>,
|
||||||
|
media_id: Option<i64>,
|
||||||
|
visitor_hash: String,
|
||||||
|
meta: RequestMeta,
|
||||||
|
) {
|
||||||
|
let mut event = AnalyticsEvent {
|
||||||
|
id: Auto::auto(),
|
||||||
|
event_type: event_type.as_str().to_string(),
|
||||||
|
client_id: client_id.map(|id| ForeignKey::PrimaryKey(Auto::fixed(id))),
|
||||||
|
media_id,
|
||||||
|
visitor_hash,
|
||||||
|
user_agent: meta.user_agent,
|
||||||
|
referer: meta.referer,
|
||||||
|
path: meta.path,
|
||||||
|
created_at: chrono::Utc::now().naive_utc(),
|
||||||
|
};
|
||||||
|
if let Err(error) = event.save(db).await {
|
||||||
|
tracing::warn!(target: "analytics", %error, "failed to record analytics event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Visitor identity (anonymous cookie) + bot filtering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reads the `vid` cookie from the request, if present.
|
||||||
|
pub fn visitor_id_from_request(request: &Request) -> Option<String> {
|
||||||
|
let cookies = request.headers().get("cookie")?.to_str().ok()?;
|
||||||
|
for part in cookies.split(';') {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some(value) = part.strip_prefix("vid=") {
|
||||||
|
let value = value.trim();
|
||||||
|
if !value.is_empty() {
|
||||||
|
return Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh random visitor id.
|
||||||
|
pub fn new_visitor_id() -> String {
|
||||||
|
uuid::Uuid::new_v4().simple().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Set-Cookie` header value for a visitor id (1 year, lax).
|
||||||
|
pub fn visitor_cookie(id: &str) -> String {
|
||||||
|
format!("{VISITOR_COOKIE}={id}; Path=/; SameSite=Lax; Max-Age=31536000")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the visitor id and whether a new cookie needs to be set on the
|
||||||
|
/// response (i.e. the request had no `vid` cookie yet).
|
||||||
|
pub fn ensure_visitor_id(request: &Request) -> (String, bool) {
|
||||||
|
match visitor_id_from_request(request) {
|
||||||
|
Some(id) => (id, false),
|
||||||
|
None => (new_visitor_id(), true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic check whether a User-Agent string belongs to a crawler/bot.
|
||||||
|
pub fn is_bot(user_agent: &str) -> bool {
|
||||||
|
let ua = user_agent.to_ascii_lowercase();
|
||||||
|
if ua.is_empty() || ua == "-" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const MARKERS: &[&str] = &[
|
||||||
|
"bot",
|
||||||
|
"crawl",
|
||||||
|
"spider",
|
||||||
|
"slurp",
|
||||||
|
"curl",
|
||||||
|
"wget",
|
||||||
|
"python-requests",
|
||||||
|
"httpclient",
|
||||||
|
"http-client",
|
||||||
|
"scrapy",
|
||||||
|
"headless",
|
||||||
|
"phantomjs",
|
||||||
|
"monitor",
|
||||||
|
"uptime",
|
||||||
|
"pingdom",
|
||||||
|
"facebookexternalhit",
|
||||||
|
"embedly",
|
||||||
|
"preview",
|
||||||
|
"fetch",
|
||||||
|
"google-",
|
||||||
|
"yandex",
|
||||||
|
"bingpreview",
|
||||||
|
"semrush",
|
||||||
|
"ahrefs",
|
||||||
|
"mj12",
|
||||||
|
"dotbot",
|
||||||
|
"petalbot",
|
||||||
|
"gptbot",
|
||||||
|
"ccbot",
|
||||||
|
];
|
||||||
|
MARKERS.iter().any(|marker| ua.contains(marker))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Aggregation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Time bucketing granularity for reports.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Granularity {
|
||||||
|
Day,
|
||||||
|
Week,
|
||||||
|
Month,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Granularity {
|
||||||
|
pub fn from_code(code: &str) -> Self {
|
||||||
|
match code {
|
||||||
|
"week" => Self::Week,
|
||||||
|
"month" => Self::Month,
|
||||||
|
_ => Self::Day,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The label for the bucket a given date falls into.
|
||||||
|
fn bucket_label(self, date: NaiveDate) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Day => date.format("%Y-%m-%d").to_string(),
|
||||||
|
Self::Week => {
|
||||||
|
let monday =
|
||||||
|
date - Duration::days(i64::from(date.weekday().num_days_from_monday()));
|
||||||
|
monday.format("%Y-%m-%d").to_string()
|
||||||
|
}
|
||||||
|
Self::Month => date.format("%Y-%m").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One time-series metric.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Metric {
|
||||||
|
pub key: &'static str,
|
||||||
|
pub values: Vec<u64>,
|
||||||
|
pub total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An entry in a "top" ranking (top clients / top media).
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TopEntry {
|
||||||
|
pub label: String,
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full aggregated report returned to the dashboard as JSON.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Report {
|
||||||
|
pub labels: Vec<String>,
|
||||||
|
pub metrics: Vec<Metric>,
|
||||||
|
pub top_clients: Vec<TopEntry>,
|
||||||
|
pub top_media: Vec<TopEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the ordered list of bucket labels covering `[from, to]` (inclusive),
|
||||||
|
/// plus a lookup from label to its index.
|
||||||
|
fn build_buckets(
|
||||||
|
from: NaiveDate,
|
||||||
|
to: NaiveDate,
|
||||||
|
granularity: Granularity,
|
||||||
|
) -> (Vec<String>, HashMap<String, usize>) {
|
||||||
|
let mut labels = Vec::new();
|
||||||
|
let mut index = HashMap::new();
|
||||||
|
let mut day = from;
|
||||||
|
while day <= to {
|
||||||
|
let label = granularity.bucket_label(day);
|
||||||
|
if !index.contains_key(&label) {
|
||||||
|
index.insert(label.clone(), labels.len());
|
||||||
|
labels.push(label);
|
||||||
|
}
|
||||||
|
day += Duration::days(1);
|
||||||
|
}
|
||||||
|
(labels, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregates raw events into a time-series report. Pure and deterministic so it
|
||||||
|
/// can be unit-tested without a database.
|
||||||
|
pub fn aggregate(
|
||||||
|
events: &[AnalyticsEvent],
|
||||||
|
from: NaiveDate,
|
||||||
|
to: NaiveDate,
|
||||||
|
granularity: Granularity,
|
||||||
|
) -> Report {
|
||||||
|
let (labels, index) = build_buckets(from, to, granularity);
|
||||||
|
let n = labels.len();
|
||||||
|
|
||||||
|
let mut landing_views = vec![0u64; n];
|
||||||
|
let mut landing_unique_sets: Vec<HashSet<&str>> = vec![HashSet::new(); n];
|
||||||
|
let mut portal_opens = vec![0u64; n];
|
||||||
|
let mut media_views = vec![0u64; n];
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let date = event.created_at.date();
|
||||||
|
if date < from || date > to {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(&bucket) = index.get(&granularity.bucket_label(date)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match event.event_type.as_str() {
|
||||||
|
"landing_view" => {
|
||||||
|
landing_views[bucket] += 1;
|
||||||
|
landing_unique_sets[bucket].insert(event.visitor_hash.as_str());
|
||||||
|
}
|
||||||
|
"portal_open" => portal_opens[bucket] += 1,
|
||||||
|
"media_view" => media_views[bucket] += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let landing_unique: Vec<u64> = landing_unique_sets
|
||||||
|
.iter()
|
||||||
|
.map(|set| set.len() as u64)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let metric = |key: &'static str, values: Vec<u64>| Metric {
|
||||||
|
total: values.iter().sum(),
|
||||||
|
key,
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
|
||||||
|
Report {
|
||||||
|
labels,
|
||||||
|
metrics: vec![
|
||||||
|
metric("landing_views", landing_views),
|
||||||
|
metric("landing_unique", landing_unique),
|
||||||
|
metric("portal_opens", portal_opens),
|
||||||
|
metric("media_views", media_views),
|
||||||
|
],
|
||||||
|
top_clients: Vec::new(),
|
||||||
|
top_media: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads events in `[from, to]` (inclusive of the whole `to` day) from the
|
||||||
|
/// database.
|
||||||
|
pub async fn load_events(
|
||||||
|
db: &Database,
|
||||||
|
from: NaiveDate,
|
||||||
|
to: NaiveDate,
|
||||||
|
) -> cot::Result<Vec<AnalyticsEvent>> {
|
||||||
|
let start: NaiveDateTime = from.and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
let end: NaiveDateTime = to.and_hms_opt(23, 59, 59).unwrap();
|
||||||
|
Ok(
|
||||||
|
query!(AnalyticsEvent, $created_at >= start && $created_at <= end)
|
||||||
|
.all(db)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this visitor already opened this media within the given window.
|
||||||
|
///
|
||||||
|
/// Used to count a media open once regardless of storage backend or file type:
|
||||||
|
/// a video streams via many HTTP range requests, and this collapses them (plus
|
||||||
|
/// quick re-opens) into a single counted view.
|
||||||
|
pub async fn media_viewed_recently(
|
||||||
|
db: &Database,
|
||||||
|
media_id: i64,
|
||||||
|
visitor: &str,
|
||||||
|
within: Duration,
|
||||||
|
) -> cot::Result<bool> {
|
||||||
|
let cutoff = chrono::Utc::now().naive_utc() - within;
|
||||||
|
let visitor_owned = visitor.to_string();
|
||||||
|
let events = query!(AnalyticsEvent, $visitor_hash == visitor_owned)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(events.iter().any(|e| {
|
||||||
|
e.event_type == "media_view" && e.media_id == Some(media_id) && e.created_at >= cutoff
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a page of recent events, newest first, for the event log.
|
||||||
|
///
|
||||||
|
/// Cursor pagination by descending id: pass the id of the oldest event already
|
||||||
|
/// shown as `before` to fetch the next (older) page. `None` starts from newest.
|
||||||
|
///
|
||||||
|
/// The ORM exposes neither `ORDER BY` nor raw row reads, so ordering and slicing
|
||||||
|
/// happen in Rust. This is fine for the expected event volume of this site; if
|
||||||
|
/// the table ever grows very large, add a descending index and raw SQL here.
|
||||||
|
/// `types`: `None` returns all event types; `Some(list)` returns only those
|
||||||
|
/// (an empty list therefore returns nothing).
|
||||||
|
pub async fn recent_events(
|
||||||
|
db: &Database,
|
||||||
|
before: Option<i64>,
|
||||||
|
limit: usize,
|
||||||
|
types: Option<&[String]>,
|
||||||
|
) -> cot::Result<Vec<AnalyticsEvent>> {
|
||||||
|
// `Auto<i64>` is not `Ord`, so the id cursor can't be pushed into the query;
|
||||||
|
// filter and sort in Rust instead (acceptable for this site's volume).
|
||||||
|
let mut events = AnalyticsEvent::objects().all(db).await?;
|
||||||
|
if let Some(types) = types {
|
||||||
|
events.retain(|e| types.iter().any(|t| t == &e.event_type));
|
||||||
|
}
|
||||||
|
events.sort_by_key(|e| std::cmp::Reverse(e.id.unwrap()));
|
||||||
|
Ok(events
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| before.is_none_or(|cursor| e.id.unwrap() < cursor))
|
||||||
|
.take(limit)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts `portal_open` events per client id within the event slice.
|
||||||
|
pub fn portal_opens_by_client(events: &[AnalyticsEvent]) -> HashMap<i64, u64> {
|
||||||
|
let mut counts: HashMap<i64, u64> = HashMap::new();
|
||||||
|
for event in events {
|
||||||
|
if event.event_type == "portal_open"
|
||||||
|
&& let Some(fk) = &event.client_id
|
||||||
|
{
|
||||||
|
let id = fk.primary_key().unwrap();
|
||||||
|
*counts.entry(id).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts `media_view` events per media id within the event slice.
|
||||||
|
pub fn media_views_by_id(events: &[AnalyticsEvent]) -> HashMap<i64, u64> {
|
||||||
|
let mut counts: HashMap<i64, u64> = HashMap::new();
|
||||||
|
for event in events {
|
||||||
|
if event.event_type == "media_view"
|
||||||
|
&& let Some(id) = event.media_id
|
||||||
|
{
|
||||||
|
*counts.entry(id).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns an id->count map into a descending top-N ranking using the supplied
|
||||||
|
/// label resolver.
|
||||||
|
pub fn top_entries<F>(counts: HashMap<i64, u64>, limit: usize, label_of: F) -> Vec<TopEntry>
|
||||||
|
where
|
||||||
|
F: Fn(i64) -> String,
|
||||||
|
{
|
||||||
|
let mut entries: Vec<(i64, u64)> = counts.into_iter().collect();
|
||||||
|
entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||||
|
entries.truncate(limit);
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, count)| TopEntry {
|
||||||
|
label: label_of(id),
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn event(kind: &str, day: &str, visitor: &str) -> AnalyticsEvent {
|
||||||
|
AnalyticsEvent {
|
||||||
|
id: Auto::fixed(0),
|
||||||
|
event_type: kind.to_string(),
|
||||||
|
client_id: None,
|
||||||
|
media_id: None,
|
||||||
|
visitor_hash: visitor.to_string(),
|
||||||
|
user_agent: None,
|
||||||
|
referer: None,
|
||||||
|
path: None,
|
||||||
|
created_at: NaiveDate::parse_from_str(day, "%Y-%m-%d")
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_opt(12, 0, 0)
|
||||||
|
.unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date(s: &str) -> NaiveDate {
|
||||||
|
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bot_detection() {
|
||||||
|
assert!(is_bot("Googlebot/2.1"));
|
||||||
|
assert!(is_bot("curl/8.0"));
|
||||||
|
assert!(is_bot(""));
|
||||||
|
assert!(is_bot("-"));
|
||||||
|
assert!(!is_bot(
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daily_buckets_cover_full_range() {
|
||||||
|
let events = vec![
|
||||||
|
event("landing_view", "2026-09-01", "a"),
|
||||||
|
event("landing_view", "2026-09-01", "a"), // repeat visitor -> not unique
|
||||||
|
event("landing_view", "2026-09-01", "b"),
|
||||||
|
event("landing_view", "2026-09-03", "c"),
|
||||||
|
event("portal_open", "2026-09-03", "c"),
|
||||||
|
event("media_view", "2026-09-03", "c"),
|
||||||
|
];
|
||||||
|
let report = aggregate(
|
||||||
|
&events,
|
||||||
|
date("2026-09-01"),
|
||||||
|
date("2026-09-03"),
|
||||||
|
Granularity::Day,
|
||||||
|
);
|
||||||
|
assert_eq!(report.labels, ["2026-09-01", "2026-09-02", "2026-09-03"]);
|
||||||
|
|
||||||
|
let landing = &report.metrics[0];
|
||||||
|
assert_eq!(landing.key, "landing_views");
|
||||||
|
assert_eq!(landing.values, [3, 0, 1]);
|
||||||
|
assert_eq!(landing.total, 4);
|
||||||
|
|
||||||
|
let unique = &report.metrics[1];
|
||||||
|
assert_eq!(unique.key, "landing_unique");
|
||||||
|
assert_eq!(unique.values, [2, 0, 1]);
|
||||||
|
assert_eq!(unique.total, 3);
|
||||||
|
|
||||||
|
assert_eq!(report.metrics[2].values, [0, 0, 1]); // portal opens
|
||||||
|
assert_eq!(report.metrics[3].values, [0, 0, 1]); // media views
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn events_outside_range_are_ignored() {
|
||||||
|
let events = vec![
|
||||||
|
event("landing_view", "2026-08-31", "a"),
|
||||||
|
event("landing_view", "2026-09-02", "b"),
|
||||||
|
];
|
||||||
|
let report = aggregate(
|
||||||
|
&events,
|
||||||
|
date("2026-09-01"),
|
||||||
|
date("2026-09-02"),
|
||||||
|
Granularity::Day,
|
||||||
|
);
|
||||||
|
assert_eq!(report.metrics[0].total, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weekly_bucketing_groups_by_monday() {
|
||||||
|
// 2026-09-01 is a Tuesday; its week's Monday is 2026-08-31.
|
||||||
|
let events = vec![
|
||||||
|
event("landing_view", "2026-09-01", "a"),
|
||||||
|
event("landing_view", "2026-09-06", "b"), // Sunday, same ISO week
|
||||||
|
event("landing_view", "2026-09-07", "c"), // Monday, next week
|
||||||
|
];
|
||||||
|
let report = aggregate(
|
||||||
|
&events,
|
||||||
|
date("2026-09-01"),
|
||||||
|
date("2026-09-07"),
|
||||||
|
Granularity::Week,
|
||||||
|
);
|
||||||
|
assert_eq!(report.labels, ["2026-08-31", "2026-09-07"]);
|
||||||
|
assert_eq!(report.metrics[0].values, [2, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn top_entries_are_sorted_desc_and_truncated() {
|
||||||
|
let mut counts = HashMap::new();
|
||||||
|
counts.insert(1, 5);
|
||||||
|
counts.insert(2, 9);
|
||||||
|
counts.insert(3, 9);
|
||||||
|
let top = top_entries(counts, 2, |id| format!("c{id}"));
|
||||||
|
assert_eq!(top.len(), 2);
|
||||||
|
// ties broken by ascending id -> c2 before c3
|
||||||
|
assert_eq!(top[0].label, "c2");
|
||||||
|
assert_eq!(top[0].count, 9);
|
||||||
|
assert_eq!(top[1].label, "c3");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+382
-9
@@ -103,8 +103,11 @@ pub struct Translations {
|
|||||||
pub clients_media_link: &'static str,
|
pub clients_media_link: &'static str,
|
||||||
pub clients_add_title: &'static str,
|
pub clients_add_title: &'static str,
|
||||||
pub clients_add_button: &'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_active: &'static str,
|
||||||
pub client_status_archived: &'static str,
|
pub client_status_archived: &'static str,
|
||||||
|
pub client_status_deleted: &'static str,
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
pub users_title: &'static str,
|
pub users_title: &'static str,
|
||||||
@@ -118,6 +121,8 @@ pub struct Translations {
|
|||||||
pub users_add_button: &'static str,
|
pub users_add_button: &'static str,
|
||||||
pub users_error_passwords_mismatch: &'static str,
|
pub users_error_passwords_mismatch: &'static str,
|
||||||
pub users_error_login_taken: &'static str,
|
pub users_error_login_taken: &'static str,
|
||||||
|
pub users_telegram_chat_id: &'static str,
|
||||||
|
pub users_telegram_enabled: &'static str,
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
pub settings_title: &'static str,
|
pub settings_title: &'static str,
|
||||||
@@ -126,13 +131,103 @@ pub struct Translations {
|
|||||||
pub settings_save: &'static str,
|
pub settings_save: &'static str,
|
||||||
pub settings_saved: &'static str,
|
pub settings_saved: &'static str,
|
||||||
pub settings_empty: &'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_bot_token: &'static str,
|
||||||
pub settings_telegram_chat_id: &'static str,
|
pub settings_telegram_chat_id: &'static str,
|
||||||
pub settings_contact_info: &'static str,
|
pub settings_contact_info: &'static str,
|
||||||
pub settings_pricing_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_contact_label: &'static str,
|
||||||
pub landing_pricing_title: &'static str,
|
pub landing_pricing_title: &'static str,
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
pub nav_analytics: &'static str,
|
||||||
|
pub analytics_title: &'static str,
|
||||||
|
pub analytics_intro: &'static str,
|
||||||
|
pub analytics_metric_landing_views: &'static str,
|
||||||
|
pub analytics_metric_landing_unique: &'static str,
|
||||||
|
pub analytics_metric_portal_opens: &'static str,
|
||||||
|
pub analytics_metric_media_views: &'static str,
|
||||||
|
pub analytics_range_from: &'static str,
|
||||||
|
pub analytics_range_to: &'static str,
|
||||||
|
pub analytics_granularity: &'static str,
|
||||||
|
pub analytics_gran_day: &'static str,
|
||||||
|
pub analytics_gran_week: &'static str,
|
||||||
|
pub analytics_gran_month: &'static str,
|
||||||
|
pub analytics_preset_7: &'static str,
|
||||||
|
pub analytics_preset_30: &'static str,
|
||||||
|
pub analytics_preset_90: &'static str,
|
||||||
|
pub analytics_preset_365: &'static str,
|
||||||
|
pub analytics_metrics_label: &'static str,
|
||||||
|
pub analytics_top_clients: &'static str,
|
||||||
|
pub analytics_top_media: &'static str,
|
||||||
|
pub analytics_count: &'static str,
|
||||||
|
pub analytics_no_data: &'static str,
|
||||||
|
pub analytics_loading: &'static str,
|
||||||
|
pub analytics_load_error: &'static str,
|
||||||
|
pub analytics_summary_title: &'static str,
|
||||||
|
pub analytics_summary_period: &'static str,
|
||||||
|
pub analytics_open_dashboard: &'static str,
|
||||||
|
pub analytics_log_title: &'static str,
|
||||||
|
pub analytics_log_show: &'static str,
|
||||||
|
pub analytics_log_empty: &'static str,
|
||||||
|
pub analytics_log_end: &'static str,
|
||||||
|
pub analytics_log_visitor: &'static str,
|
||||||
|
pub analytics_ev_landing_view: &'static str,
|
||||||
|
pub analytics_ev_portal_open: &'static str,
|
||||||
|
pub analytics_ev_media_view: &'static str,
|
||||||
|
pub analytics_ev_landing_short: &'static str,
|
||||||
|
pub analytics_ev_portal_short: &'static str,
|
||||||
|
pub analytics_ev_media_short: &'static str,
|
||||||
|
pub analytics_log_media_prefix: &'static str,
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
pub dashboard_title: &'static str,
|
pub dashboard_title: &'static str,
|
||||||
pub dashboard_today_visits: &'static str,
|
pub dashboard_today_visits: &'static str,
|
||||||
@@ -144,6 +239,11 @@ pub struct Translations {
|
|||||||
pub login_title: &'static str,
|
pub login_title: &'static str,
|
||||||
pub login_button: &'static str,
|
pub login_button: &'static str,
|
||||||
pub login_error: &'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 logout: &'static str,
|
||||||
pub setup_title: &'static str,
|
pub setup_title: &'static str,
|
||||||
pub setup_description: &'static str,
|
pub setup_description: &'static str,
|
||||||
@@ -185,6 +285,12 @@ pub struct Translations {
|
|||||||
pub landing_footer_text: &'static str,
|
pub landing_footer_text: &'static str,
|
||||||
pub landing_footer_copyright: &'static str,
|
pub landing_footer_copyright: &'static str,
|
||||||
|
|
||||||
|
// Cookies & privacy
|
||||||
|
pub cookie_banner_text: &'static str,
|
||||||
|
pub cookie_banner_accept: &'static str,
|
||||||
|
pub cookie_privacy_link: &'static str,
|
||||||
|
pub privacy_title: &'static str,
|
||||||
|
|
||||||
// Testimonials admin
|
// Testimonials admin
|
||||||
pub nav_testimonials: &'static str,
|
pub nav_testimonials: &'static str,
|
||||||
pub testimonials_title: &'static str,
|
pub testimonials_title: &'static str,
|
||||||
@@ -233,6 +339,7 @@ pub struct Translations {
|
|||||||
pub visit_status_scheduled: &'static str,
|
pub visit_status_scheduled: &'static str,
|
||||||
pub visit_status_completed: &'static str,
|
pub visit_status_completed: &'static str,
|
||||||
pub visit_status_cancelled: &'static str,
|
pub visit_status_cancelled: &'static str,
|
||||||
|
pub visit_status_deleted: &'static str,
|
||||||
pub schedule_mark_done: &'static str,
|
pub schedule_mark_done: &'static str,
|
||||||
pub schedule_cancel: &'static str,
|
pub schedule_cancel: &'static str,
|
||||||
pub schedule_edit_title: &'static str,
|
pub schedule_edit_title: &'static str,
|
||||||
@@ -253,6 +360,11 @@ pub struct Translations {
|
|||||||
pub media_delete: &'static str,
|
pub media_delete: &'static str,
|
||||||
pub media_delete_confirm: &'static str,
|
pub media_delete_confirm: &'static str,
|
||||||
pub media_all_clients: &'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
|
// Client portal
|
||||||
pub portal_title: &'static str,
|
pub portal_title: &'static str,
|
||||||
@@ -265,6 +377,19 @@ pub struct Translations {
|
|||||||
pub portal_feedback_submit: &'static str,
|
pub portal_feedback_submit: &'static str,
|
||||||
pub portal_feedback_thanks: &'static str,
|
pub portal_feedback_thanks: &'static str,
|
||||||
pub portal_link: &'static str,
|
pub portal_link: &'static str,
|
||||||
|
pub portal_settings: &'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
|
// Common
|
||||||
pub no_value: &'static str,
|
pub no_value: &'static str,
|
||||||
@@ -281,7 +406,7 @@ static RU: Translations = Translations {
|
|||||||
nav_visits: "Визиты",
|
nav_visits: "Визиты",
|
||||||
nav_users: "Админы",
|
nav_users: "Админы",
|
||||||
nav_settings: "Настройки",
|
nav_settings: "Настройки",
|
||||||
nav_title: "Пет-ситтинг",
|
nav_title: "МурНяня.РФ",
|
||||||
|
|
||||||
leads_title: "Заявки",
|
leads_title: "Заявки",
|
||||||
leads_empty: "Заявок пока нет.",
|
leads_empty: "Заявок пока нет.",
|
||||||
@@ -313,8 +438,11 @@ static RU: Translations = Translations {
|
|||||||
clients_media_link: "Медиа",
|
clients_media_link: "Медиа",
|
||||||
clients_add_title: "Добавить клиента",
|
clients_add_title: "Добавить клиента",
|
||||||
clients_add_button: "Добавить",
|
clients_add_button: "Добавить",
|
||||||
|
clients_delete: "Удалить клиента",
|
||||||
|
clients_delete_confirm: "Точно удалить этого клиента?",
|
||||||
client_status_active: "Активный",
|
client_status_active: "Активный",
|
||||||
client_status_archived: "Архив",
|
client_status_archived: "Архив",
|
||||||
|
client_status_deleted: "Удалён",
|
||||||
|
|
||||||
users_title: "Администраторы",
|
users_title: "Администраторы",
|
||||||
users_login: "Логин",
|
users_login: "Логин",
|
||||||
@@ -327,6 +455,8 @@ static RU: Translations = Translations {
|
|||||||
users_add_button: "Добавить",
|
users_add_button: "Добавить",
|
||||||
users_error_passwords_mismatch: "Пароли не совпадают.",
|
users_error_passwords_mismatch: "Пароли не совпадают.",
|
||||||
users_error_login_taken: "Этот логин уже занят.",
|
users_error_login_taken: "Этот логин уже занят.",
|
||||||
|
users_telegram_chat_id: "Telegram Chat ID",
|
||||||
|
users_telegram_enabled: "Уведомления",
|
||||||
|
|
||||||
settings_title: "Настройки",
|
settings_title: "Настройки",
|
||||||
settings_key: "Параметр",
|
settings_key: "Параметр",
|
||||||
@@ -334,13 +464,102 @@ static RU: Translations = Translations {
|
|||||||
settings_save: "Сохранить",
|
settings_save: "Сохранить",
|
||||||
settings_saved: "Сохранено!",
|
settings_saved: "Сохранено!",
|
||||||
settings_empty: "Настройки не заданы.",
|
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_bot_token: "Токен Telegram бота",
|
||||||
settings_telegram_chat_id: "Chat ID для уведомлений",
|
settings_telegram_chat_id: "Chat ID для уведомлений",
|
||||||
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
||||||
settings_pricing_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_contact_label: "Или свяжитесь с нами напрямую",
|
||||||
landing_pricing_title: "Стоимость",
|
landing_pricing_title: "Стоимость",
|
||||||
|
|
||||||
|
nav_analytics: "Статистика",
|
||||||
|
analytics_title: "Статистика посещений",
|
||||||
|
analytics_intro: "Уникальные заходы на сайт, открытия клиентских страниц и просмотры фото из архива.",
|
||||||
|
analytics_metric_landing_views: "Заходы на сайт",
|
||||||
|
analytics_metric_landing_unique: "Уникальные посетители",
|
||||||
|
analytics_metric_portal_opens: "Открытия страниц клиентов",
|
||||||
|
analytics_metric_media_views: "Просмотры фото",
|
||||||
|
analytics_range_from: "С",
|
||||||
|
analytics_range_to: "По",
|
||||||
|
analytics_granularity: "Разбивка",
|
||||||
|
analytics_gran_day: "По дням",
|
||||||
|
analytics_gran_week: "По неделям",
|
||||||
|
analytics_gran_month: "По месяцам",
|
||||||
|
analytics_preset_7: "7 дней",
|
||||||
|
analytics_preset_30: "30 дней",
|
||||||
|
analytics_preset_90: "90 дней",
|
||||||
|
analytics_preset_365: "Год",
|
||||||
|
analytics_metrics_label: "Метрики",
|
||||||
|
analytics_top_clients: "Топ клиентов по открытиям страницы",
|
||||||
|
analytics_top_media: "Топ фото по просмотрам",
|
||||||
|
analytics_count: "Количество",
|
||||||
|
analytics_no_data: "Нет данных за выбранный период.",
|
||||||
|
analytics_loading: "Загрузка...",
|
||||||
|
analytics_load_error: "Не удалось загрузить данные. Обновите страницу.",
|
||||||
|
analytics_summary_title: "Статистика посещений",
|
||||||
|
analytics_summary_period: "за последние 30 дней",
|
||||||
|
analytics_open_dashboard: "Открыть подробную статистику",
|
||||||
|
analytics_log_title: "Последние события",
|
||||||
|
analytics_log_show: "Показывать",
|
||||||
|
analytics_log_empty: "Событий пока нет.",
|
||||||
|
analytics_log_end: "Это все события.",
|
||||||
|
analytics_log_visitor: "Посетитель",
|
||||||
|
analytics_ev_landing_view: "Заход на сайт",
|
||||||
|
analytics_ev_portal_open: "Открытие страницы клиента",
|
||||||
|
analytics_ev_media_view: "Просмотр фото",
|
||||||
|
analytics_ev_landing_short: "Сайт",
|
||||||
|
analytics_ev_portal_short: "Портал",
|
||||||
|
analytics_ev_media_short: "Фото",
|
||||||
|
analytics_log_media_prefix: "Просмотр медиа",
|
||||||
|
|
||||||
dashboard_title: "Главная",
|
dashboard_title: "Главная",
|
||||||
dashboard_today_visits: "Визиты на сегодня",
|
dashboard_today_visits: "Визиты на сегодня",
|
||||||
dashboard_no_visits: "На сегодня визитов нет.",
|
dashboard_no_visits: "На сегодня визитов нет.",
|
||||||
@@ -357,8 +576,13 @@ static RU: Translations = Translations {
|
|||||||
media_delete: "Удалить",
|
media_delete: "Удалить",
|
||||||
media_delete_confirm: "Удалить этот файл?",
|
media_delete_confirm: "Удалить этот файл?",
|
||||||
media_all_clients: "Все клиенты",
|
media_all_clients: "Все клиенты",
|
||||||
|
media_files_selected: "Выбрано файлов",
|
||||||
|
media_upload_sending: "Загрузка на сервер...",
|
||||||
|
media_upload_processing: "Конвертация и загрузка в R2...",
|
||||||
|
media_upload_done: "Готово — обновляем медиагалерею...",
|
||||||
|
media_upload_connection_error: "Ошибка соединения",
|
||||||
|
|
||||||
portal_title: "Мои визиты",
|
portal_title: "Визиты",
|
||||||
portal_upcoming: "Предстоящие визиты",
|
portal_upcoming: "Предстоящие визиты",
|
||||||
portal_past: "Прошлые визиты",
|
portal_past: "Прошлые визиты",
|
||||||
portal_no_upcoming: "Нет предстоящих визитов.",
|
portal_no_upcoming: "Нет предстоящих визитов.",
|
||||||
@@ -368,10 +592,28 @@ static RU: Translations = Translations {
|
|||||||
portal_feedback_submit: "Отправить",
|
portal_feedback_submit: "Отправить",
|
||||||
portal_feedback_thanks: "Спасибо за отзыв!",
|
portal_feedback_thanks: "Спасибо за отзыв!",
|
||||||
portal_link: "Ссылка клиента",
|
portal_link: "Ссылка клиента",
|
||||||
|
portal_settings: "Настройки",
|
||||||
|
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_title: "Вход в систему",
|
||||||
login_button: "Войти",
|
login_button: "Войти",
|
||||||
login_error: "Неверный логин или пароль.",
|
login_error: "Неверный логин или пароль.",
|
||||||
|
login_sso_button: "Войти через SSO",
|
||||||
|
login_sso_error: "Ошибка SSO-авторизации.",
|
||||||
|
login_sso_error_group: "У вас нет доступа: вы не состоите в разрешённой группе.",
|
||||||
|
login_sso_error_provider: "Не удалось связаться с провайдером авторизации.",
|
||||||
|
login_sso_error_user_disabled: "Ваша учётная запись отключена.",
|
||||||
logout: "Выйти",
|
logout: "Выйти",
|
||||||
setup_title: "Создание администратора",
|
setup_title: "Создание администратора",
|
||||||
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
||||||
@@ -390,7 +632,7 @@ static RU: Translations = Translations {
|
|||||||
schedule_new_title: "Запланировать визиты",
|
schedule_new_title: "Запланировать визиты",
|
||||||
schedule_client: "Клиент",
|
schedule_client: "Клиент",
|
||||||
schedule_admin: "Исполнитель",
|
schedule_admin: "Исполнитель",
|
||||||
schedule_default_time: "Время по умолчанию",
|
schedule_default_time: "Время",
|
||||||
schedule_time_start: "С",
|
schedule_time_start: "С",
|
||||||
schedule_time_end: "До",
|
schedule_time_end: "До",
|
||||||
schedule_pick_dates: "Добавить дату",
|
schedule_pick_dates: "Добавить дату",
|
||||||
@@ -407,6 +649,7 @@ static RU: Translations = Translations {
|
|||||||
visit_status_scheduled: "Запланирован",
|
visit_status_scheduled: "Запланирован",
|
||||||
visit_status_completed: "Выполнен",
|
visit_status_completed: "Выполнен",
|
||||||
visit_status_cancelled: "Отменён",
|
visit_status_cancelled: "Отменён",
|
||||||
|
visit_status_deleted: "Удалён",
|
||||||
schedule_mark_done: "Выполнен",
|
schedule_mark_done: "Выполнен",
|
||||||
schedule_cancel: "Отменить",
|
schedule_cancel: "Отменить",
|
||||||
schedule_edit_title: "Редактировать визит",
|
schedule_edit_title: "Редактировать визит",
|
||||||
@@ -417,7 +660,7 @@ static RU: Translations = Translations {
|
|||||||
schedule_delete_confirm: "Точно удалить этот визит?",
|
schedule_delete_confirm: "Точно удалить этот визит?",
|
||||||
|
|
||||||
landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!",
|
landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!",
|
||||||
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома",
|
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома. Город Хабаровск",
|
||||||
landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.",
|
landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.",
|
||||||
landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
|
landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
|
||||||
landing_hero_cta: "Оставить заявку",
|
landing_hero_cta: "Оставить заявку",
|
||||||
@@ -448,9 +691,14 @@ static RU: Translations = Translations {
|
|||||||
landing_guarantee: "Порядочность, честность и строгое выполнение ваших требований гарантировано.",
|
landing_guarantee: "Порядочность, честность и строгое выполнение ваших требований гарантировано.",
|
||||||
landing_testimonials_title: "Отзывы",
|
landing_testimonials_title: "Отзывы",
|
||||||
landing_form_consent: "Я даю согласие на обработку персональных данных",
|
landing_form_consent: "Я даю согласие на обработку персональных данных",
|
||||||
landing_footer_text: "Пет-ситтинг — забота о вашем питомце",
|
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
|
||||||
landing_footer_copyright: "Все права защищены",
|
landing_footer_copyright: "Все права защищены",
|
||||||
|
|
||||||
|
cookie_banner_text: "Мы используем файлы cookie для работы сайта и анонимной статистики посещений.",
|
||||||
|
cookie_banner_accept: "Принять",
|
||||||
|
cookie_privacy_link: "Политика конфиденциальности",
|
||||||
|
privacy_title: "Политика конфиденциальности и использования cookie",
|
||||||
|
|
||||||
nav_testimonials: "Отзывы",
|
nav_testimonials: "Отзывы",
|
||||||
testimonials_title: "Отзывы",
|
testimonials_title: "Отзывы",
|
||||||
testimonials_empty: "Отзывов пока нет.",
|
testimonials_empty: "Отзывов пока нет.",
|
||||||
@@ -479,7 +727,7 @@ static EN: Translations = Translations {
|
|||||||
nav_visits: "Visits",
|
nav_visits: "Visits",
|
||||||
nav_users: "Admins",
|
nav_users: "Admins",
|
||||||
nav_settings: "Settings",
|
nav_settings: "Settings",
|
||||||
nav_title: "Pet Sitting",
|
nav_title: "МурНяня.РФ",
|
||||||
|
|
||||||
leads_title: "Leads",
|
leads_title: "Leads",
|
||||||
leads_empty: "No leads yet.",
|
leads_empty: "No leads yet.",
|
||||||
@@ -511,8 +759,11 @@ static EN: Translations = Translations {
|
|||||||
clients_media_link: "Media",
|
clients_media_link: "Media",
|
||||||
clients_add_title: "Add Client",
|
clients_add_title: "Add Client",
|
||||||
clients_add_button: "Add",
|
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_active: "Active",
|
||||||
client_status_archived: "Archived",
|
client_status_archived: "Archived",
|
||||||
|
client_status_deleted: "Deleted",
|
||||||
|
|
||||||
users_title: "Administrators",
|
users_title: "Administrators",
|
||||||
users_login: "Login",
|
users_login: "Login",
|
||||||
@@ -525,6 +776,8 @@ static EN: Translations = Translations {
|
|||||||
users_add_button: "Add",
|
users_add_button: "Add",
|
||||||
users_error_passwords_mismatch: "Passwords do not match.",
|
users_error_passwords_mismatch: "Passwords do not match.",
|
||||||
users_error_login_taken: "This login is already taken.",
|
users_error_login_taken: "This login is already taken.",
|
||||||
|
users_telegram_chat_id: "Telegram Chat ID",
|
||||||
|
users_telegram_enabled: "Notifications",
|
||||||
|
|
||||||
settings_title: "Settings",
|
settings_title: "Settings",
|
||||||
settings_key: "Parameter",
|
settings_key: "Parameter",
|
||||||
@@ -532,13 +785,102 @@ static EN: Translations = Translations {
|
|||||||
settings_save: "Save",
|
settings_save: "Save",
|
||||||
settings_saved: "Saved!",
|
settings_saved: "Saved!",
|
||||||
settings_empty: "No settings configured.",
|
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_bot_token: "Telegram Bot Token",
|
||||||
settings_telegram_chat_id: "Notification Chat ID",
|
settings_telegram_chat_id: "Notification Chat ID",
|
||||||
settings_contact_info: "Contact info (shown on landing page)",
|
settings_contact_info: "Contact info (shown on landing page)",
|
||||||
settings_pricing_info: "Pricing block (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_contact_label: "Or contact us directly",
|
||||||
landing_pricing_title: "Pricing",
|
landing_pricing_title: "Pricing",
|
||||||
|
|
||||||
|
nav_analytics: "Analytics",
|
||||||
|
analytics_title: "Visit analytics",
|
||||||
|
analytics_intro: "Unique landing-page visits, client portal opens, and archive photo views.",
|
||||||
|
analytics_metric_landing_views: "Landing visits",
|
||||||
|
analytics_metric_landing_unique: "Unique visitors",
|
||||||
|
analytics_metric_portal_opens: "Client portal opens",
|
||||||
|
analytics_metric_media_views: "Photo views",
|
||||||
|
analytics_range_from: "From",
|
||||||
|
analytics_range_to: "To",
|
||||||
|
analytics_granularity: "Group by",
|
||||||
|
analytics_gran_day: "Day",
|
||||||
|
analytics_gran_week: "Week",
|
||||||
|
analytics_gran_month: "Month",
|
||||||
|
analytics_preset_7: "7 days",
|
||||||
|
analytics_preset_30: "30 days",
|
||||||
|
analytics_preset_90: "90 days",
|
||||||
|
analytics_preset_365: "Year",
|
||||||
|
analytics_metrics_label: "Metrics",
|
||||||
|
analytics_top_clients: "Top clients by portal opens",
|
||||||
|
analytics_top_media: "Top photos by views",
|
||||||
|
analytics_count: "Count",
|
||||||
|
analytics_no_data: "No data for the selected period.",
|
||||||
|
analytics_loading: "Loading...",
|
||||||
|
analytics_load_error: "Failed to load data. Please refresh the page.",
|
||||||
|
analytics_summary_title: "Visit analytics",
|
||||||
|
analytics_summary_period: "over the last 30 days",
|
||||||
|
analytics_open_dashboard: "Open detailed analytics",
|
||||||
|
analytics_log_title: "Recent events",
|
||||||
|
analytics_log_show: "Show",
|
||||||
|
analytics_log_empty: "No events yet.",
|
||||||
|
analytics_log_end: "That's all events.",
|
||||||
|
analytics_log_visitor: "Visitor",
|
||||||
|
analytics_ev_landing_view: "Landing visit",
|
||||||
|
analytics_ev_portal_open: "Client portal open",
|
||||||
|
analytics_ev_media_view: "Photo view",
|
||||||
|
analytics_ev_landing_short: "Site",
|
||||||
|
analytics_ev_portal_short: "Portal",
|
||||||
|
analytics_ev_media_short: "Photos",
|
||||||
|
analytics_log_media_prefix: "Media view",
|
||||||
|
|
||||||
dashboard_title: "Home",
|
dashboard_title: "Home",
|
||||||
dashboard_today_visits: "Today's visits",
|
dashboard_today_visits: "Today's visits",
|
||||||
dashboard_no_visits: "No visits for today.",
|
dashboard_no_visits: "No visits for today.",
|
||||||
@@ -555,8 +897,13 @@ static EN: Translations = Translations {
|
|||||||
media_delete: "Delete",
|
media_delete: "Delete",
|
||||||
media_delete_confirm: "Delete this file?",
|
media_delete_confirm: "Delete this file?",
|
||||||
media_all_clients: "All clients",
|
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: "My Visits",
|
portal_title: "Visits",
|
||||||
portal_upcoming: "Upcoming visits",
|
portal_upcoming: "Upcoming visits",
|
||||||
portal_past: "Past visits",
|
portal_past: "Past visits",
|
||||||
portal_no_upcoming: "No upcoming visits.",
|
portal_no_upcoming: "No upcoming visits.",
|
||||||
@@ -566,10 +913,28 @@ static EN: Translations = Translations {
|
|||||||
portal_feedback_submit: "Submit",
|
portal_feedback_submit: "Submit",
|
||||||
portal_feedback_thanks: "Thank you for your feedback!",
|
portal_feedback_thanks: "Thank you for your feedback!",
|
||||||
portal_link: "Client link",
|
portal_link: "Client link",
|
||||||
|
portal_settings: "Settings",
|
||||||
|
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_title: "Sign In",
|
||||||
login_button: "Sign In",
|
login_button: "Sign In",
|
||||||
login_error: "Invalid login or password.",
|
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",
|
logout: "Sign Out",
|
||||||
setup_title: "Create Administrator",
|
setup_title: "Create Administrator",
|
||||||
setup_description: "There are no administrators yet. Create the first one to get started.",
|
setup_description: "There are no administrators yet. Create the first one to get started.",
|
||||||
@@ -588,7 +953,7 @@ static EN: Translations = Translations {
|
|||||||
schedule_new_title: "Plan Visits",
|
schedule_new_title: "Plan Visits",
|
||||||
schedule_client: "Client",
|
schedule_client: "Client",
|
||||||
schedule_admin: "Assigned to",
|
schedule_admin: "Assigned to",
|
||||||
schedule_default_time: "Default Time",
|
schedule_default_time: "Time",
|
||||||
schedule_time_start: "From",
|
schedule_time_start: "From",
|
||||||
schedule_time_end: "To",
|
schedule_time_end: "To",
|
||||||
schedule_pick_dates: "Add date",
|
schedule_pick_dates: "Add date",
|
||||||
@@ -605,6 +970,7 @@ static EN: Translations = Translations {
|
|||||||
visit_status_scheduled: "Scheduled",
|
visit_status_scheduled: "Scheduled",
|
||||||
visit_status_completed: "Completed",
|
visit_status_completed: "Completed",
|
||||||
visit_status_cancelled: "Cancelled",
|
visit_status_cancelled: "Cancelled",
|
||||||
|
visit_status_deleted: "Deleted",
|
||||||
schedule_mark_done: "Done",
|
schedule_mark_done: "Done",
|
||||||
schedule_cancel: "Cancel",
|
schedule_cancel: "Cancel",
|
||||||
schedule_edit_title: "Edit Visit",
|
schedule_edit_title: "Edit Visit",
|
||||||
@@ -646,9 +1012,14 @@ static EN: Translations = Translations {
|
|||||||
landing_guarantee: "Integrity, honesty, and strict fulfillment of your requirements guaranteed.",
|
landing_guarantee: "Integrity, honesty, and strict fulfillment of your requirements guaranteed.",
|
||||||
landing_testimonials_title: "Testimonials",
|
landing_testimonials_title: "Testimonials",
|
||||||
landing_form_consent: "I consent to the processing of my personal data",
|
landing_form_consent: "I consent to the processing of my personal data",
|
||||||
landing_footer_text: "Pet Sitting — caring for your pet",
|
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
|
||||||
landing_footer_copyright: "All rights reserved",
|
landing_footer_copyright: "All rights reserved",
|
||||||
|
|
||||||
|
cookie_banner_text: "We use cookies to run the site and for anonymous visit statistics.",
|
||||||
|
cookie_banner_accept: "Accept",
|
||||||
|
cookie_privacy_link: "Privacy Policy",
|
||||||
|
privacy_title: "Privacy & Cookie Policy",
|
||||||
|
|
||||||
nav_testimonials: "Testimonials",
|
nav_testimonials: "Testimonials",
|
||||||
testimonials_title: "Testimonials",
|
testimonials_title: "Testimonials",
|
||||||
testimonials_empty: "No testimonials yet.",
|
testimonials_empty: "No testimonials yet.",
|
||||||
@@ -691,6 +1062,7 @@ impl Translations {
|
|||||||
"scheduled" => self.visit_status_scheduled,
|
"scheduled" => self.visit_status_scheduled,
|
||||||
"completed" => self.visit_status_completed,
|
"completed" => self.visit_status_completed,
|
||||||
"cancelled" => self.visit_status_cancelled,
|
"cancelled" => self.visit_status_cancelled,
|
||||||
|
"deleted" => self.visit_status_deleted,
|
||||||
_ => "?",
|
_ => "?",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,6 +1071,7 @@ impl Translations {
|
|||||||
match status {
|
match status {
|
||||||
"active" => self.client_status_active,
|
"active" => self.client_status_active,
|
||||||
"archived" => self.client_status_archived,
|
"archived" => self.client_status_archived,
|
||||||
|
"deleted" => self.client_status_deleted,
|
||||||
_ => "?",
|
_ => "?",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-13
@@ -1,18 +1,23 @@
|
|||||||
mod admin;
|
mod admin;
|
||||||
|
mod analytics;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod migrations;
|
mod migrations;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
mod public;
|
mod public;
|
||||||
mod telegram;
|
mod telegram;
|
||||||
|
mod turnstile;
|
||||||
|
mod tz;
|
||||||
|
mod uploads;
|
||||||
|
mod web_push;
|
||||||
|
|
||||||
use cot::cli::CliMetadata;
|
use cot::cli::CliMetadata;
|
||||||
use cot::config::{
|
use cot::config::{
|
||||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
|
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
|
||||||
SessionStoreTypeConfig,
|
SessionStoreConfig, SessionStoreTypeConfig,
|
||||||
};
|
};
|
||||||
use cot::db::migrations::SyncDynMigration;
|
use cot::db::migrations::SyncDynMigration;
|
||||||
use cot::middleware::SessionMiddleware;
|
use cot::middleware::SessionMiddleware;
|
||||||
use cot::project::{MiddlewareContext, RegisterAppsContext, RootHandler};
|
use cot::project::{MiddlewareContext, ProjectContext, RegisterAppsContext, RootHandler};
|
||||||
use cot::router::Router;
|
use cot::router::Router;
|
||||||
use cot::session::db::SessionApp;
|
use cot::session::db::SessionApp;
|
||||||
use cot::{App, AppBuilder, Project};
|
use cot::{App, AppBuilder, Project};
|
||||||
@@ -35,11 +40,17 @@ impl App for PettingApp {
|
|||||||
|
|
||||||
struct PublicApp;
|
struct PublicApp;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
impl App for PublicApp {
|
impl App for PublicApp {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"public"
|
"public"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn init(&self, context: &mut ProjectContext) -> cot::Result<()> {
|
||||||
|
web_push::initialize(context.database()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn router(&self) -> Router {
|
fn router(&self) -> Router {
|
||||||
public::public_router()
|
public::public_router()
|
||||||
}
|
}
|
||||||
@@ -47,24 +58,51 @@ impl App for PublicApp {
|
|||||||
|
|
||||||
struct PettingProject;
|
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 {
|
impl Project for PettingProject {
|
||||||
fn cli_metadata(&self) -> CliMetadata {
|
fn cli_metadata(&self) -> CliMetadata {
|
||||||
cot::cli::metadata!()
|
cot::cli::metadata!()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
|
fn config(&self, config_name: &str) -> cot::Result<ProjectConfig> {
|
||||||
Ok(ProjectConfig::builder()
|
Ok(ProjectConfig::builder()
|
||||||
.debug(true)
|
.debug(debug_enabled(config_name))
|
||||||
.database(
|
.database(DatabaseConfig::builder().url(database_url()).build())
|
||||||
DatabaseConfig::builder()
|
|
||||||
.url("sqlite://db.sqlite3?mode=rwc")
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.middlewares(
|
.middlewares(
|
||||||
MiddlewareConfig::builder()
|
MiddlewareConfig::builder()
|
||||||
.session(
|
.session(
|
||||||
SessionMiddlewareConfig::builder()
|
SessionMiddlewareConfig::builder()
|
||||||
.secure(false)
|
.secure(false)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
.store(
|
.store(
|
||||||
SessionStoreConfig::builder()
|
SessionStoreConfig::builder()
|
||||||
.store_type(SessionStoreTypeConfig::Database)
|
.store_type(SessionStoreTypeConfig::Database)
|
||||||
@@ -94,7 +132,32 @@ impl Project for PettingProject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cot::main]
|
fn main() {
|
||||||
fn main() -> impl Project {
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
PettingProject
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+7
-9
@@ -1,17 +1,15 @@
|
|||||||
//! List of migrations for the current app.
|
//! 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_0001_initial;
|
||||||
pub mod m_0002_visit_schedule;
|
pub mod m_0002_push_subscription;
|
||||||
pub mod m_0003_visit_feedback;
|
pub mod m_0003_analytics_event;
|
||||||
pub mod m_0004_visit_public_notes;
|
pub mod m_0004_analytics_event_meta;
|
||||||
pub mod m_0005_testimonials;
|
|
||||||
/// The list of migrations for current app.
|
/// The list of migrations for current app.
|
||||||
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
||||||
&m_0001_initial::Migration,
|
&m_0001_initial::Migration,
|
||||||
&m_0002_visit_schedule::Migration,
|
&m_0002_push_subscription::Migration,
|
||||||
&m_0003_visit_feedback::Migration,
|
&m_0003_analytics_event::Migration,
|
||||||
&m_0004_visit_public_notes::Migration,
|
&m_0004_analytics_event_meta::Migration,
|
||||||
&m_0005_testimonials::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)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
pub(super) struct Migration;
|
pub(super) struct Migration;
|
||||||
|
|
||||||
impl ::cot::db::migrations::Migration for Migration {
|
impl ::cot::db::migrations::Migration for Migration {
|
||||||
const APP_NAME: &'static str = "web-petting";
|
const APP_NAME: &'static str = "web-petting";
|
||||||
const MIGRATION_NAME: &'static str = "m_0001_initial";
|
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] = &[
|
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||||
::cot::db::migrations::Operation::create_model()
|
::cot::db::migrations::Operation::create_model()
|
||||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||||
.fields(
|
.fields(&[
|
||||||
&[
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("id"),
|
||||||
::cot::db::Identifier::new("id"),
|
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.auto()
|
||||||
.auto()
|
.primary_key()
|
||||||
.primary_key()
|
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(
|
::cot::db::migrations::Field::new(
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::Identifier::new("login"),
|
||||||
),
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("login"),
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
.unique(),
|
||||||
)
|
::cot::db::migrations::Field::new(
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
::cot::db::Identifier::new("password_hash"),
|
||||||
.unique(),
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("password_hash"),
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("display_name"),
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("display_name"),
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("telegram_chat_id"),
|
||||||
.set_null(
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
)
|
||||||
),
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("status"),
|
::cot::db::Identifier::new("telegram_notifications"),
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
<Option<bool> as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
.set_null(<Option<bool> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("created_at"),
|
::cot::db::Identifier::new("status"),
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::migrations::Field::new(
|
||||||
),
|
::cot::db::Identifier::new("created_at"),
|
||||||
::cot::db::migrations::Field::new(
|
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::Identifier::new("updated_at"),
|
)
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
)
|
::cot::db::migrations::Field::new(
|
||||||
.set_null(
|
::cot::db::Identifier::new("updated_at"),
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||||
),
|
)
|
||||||
],
|
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
)
|
])
|
||||||
.build(),
|
.build(),
|
||||||
::cot::db::migrations::Operation::create_model()
|
::cot::db::migrations::Operation::create_model()
|
||||||
.table_name(::cot::db::Identifier::new("web_petting__setting"))
|
.table_name(::cot::db::Identifier::new("web_petting__setting"))
|
||||||
.fields(
|
.fields(&[
|
||||||
&[
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("id"),
|
||||||
::cot::db::Identifier::new("id"),
|
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.auto()
|
||||||
.auto()
|
.primary_key()
|
||||||
.primary_key()
|
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(
|
::cot::db::migrations::Field::new(
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::Identifier::new("key"),
|
||||||
),
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("key"),
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
.unique(),
|
||||||
)
|
::cot::db::migrations::Field::new(
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
::cot::db::Identifier::new("value"),
|
||||||
.unique(),
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("value"),
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("updated_at"),
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("updated_at"),
|
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
])
|
||||||
)
|
|
||||||
.set_null(
|
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.build(),
|
.build(),
|
||||||
::cot::db::migrations::Operation::create_model()
|
::cot::db::migrations::Operation::create_model()
|
||||||
.table_name(::cot::db::Identifier::new("web_petting__client"))
|
.table_name(::cot::db::Identifier::new("web_petting__client"))
|
||||||
.fields(
|
.fields(&[
|
||||||
&[
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("id"),
|
||||||
::cot::db::Identifier::new("id"),
|
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.auto()
|
||||||
.auto()
|
.primary_key()
|
||||||
.primary_key()
|
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(
|
::cot::db::migrations::Field::new(
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::Identifier::new("name"),
|
||||||
),
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("name"),
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("phone"),
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("phone"),
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("email"),
|
||||||
.set_null(
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
)
|
||||||
),
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("email"),
|
::cot::db::Identifier::new("address"),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::migrations::Field::new(
|
||||||
),
|
::cot::db::Identifier::new("notes"),
|
||||||
::cot::db::migrations::Field::new(
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
::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(
|
||||||
.set_null(
|
::cot::db::Identifier::new("media_token"),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
),
|
)
|
||||||
::cot::db::migrations::Field::new(
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||||
::cot::db::Identifier::new("notes"),
|
.unique(),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("color"),
|
||||||
.set_null(
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
)
|
||||||
),
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("media_token"),
|
::cot::db::Identifier::new("status"),
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.unique(),
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("created_at"),
|
||||||
::cot::db::Identifier::new("status"),
|
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("updated_at"),
|
||||||
::cot::db::Identifier::new("created_at"),
|
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(
|
])
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
.build(),
|
||||||
),
|
::cot::db::migrations::Operation::create_model()
|
||||||
::cot::db::migrations::Field::new(
|
.table_name(::cot::db::Identifier::new("web_petting__testimonial"))
|
||||||
::cot::db::Identifier::new("updated_at"),
|
.fields(&[
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("id"),
|
||||||
.set_null(
|
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
)
|
||||||
),
|
.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(),
|
.build(),
|
||||||
::cot::db::migrations::Operation::create_model()
|
::cot::db::migrations::Operation::create_model()
|
||||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||||
.fields(
|
.fields(&[
|
||||||
&[
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("id"),
|
||||||
::cot::db::Identifier::new("id"),
|
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.auto()
|
||||||
.auto()
|
.primary_key()
|
||||||
.primary_key()
|
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(
|
::cot::db::migrations::Field::new(
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::Identifier::new("client_id"),
|
||||||
),
|
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("client_id"),
|
.foreign_key(
|
||||||
<cot::db::ForeignKey<
|
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||||
crate::models::Client,
|
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||||
> as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||||
)
|
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||||
.foreign_key(
|
)
|
||||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
.set_null(
|
||||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::NULLABLE,
|
||||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
),
|
||||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("user_id"),
|
||||||
.set_null(
|
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<cot::db::ForeignKey<
|
)
|
||||||
crate::models::Client,
|
.foreign_key(
|
||||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
<crate::models::User as ::cot::db::Model>::TABLE_NAME,
|
||||||
),
|
<crate::models::User as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||||
::cot::db::Identifier::new("scheduled_at"),
|
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.set_null(
|
||||||
.set_null(
|
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::NULLABLE,
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
),
|
||||||
),
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("visit_date"),
|
||||||
::cot::db::Identifier::new("duration_minutes"),
|
<chrono::NaiveDate as ::cot::db::DatabaseField>::TYPE,
|
||||||
<Option<i32> as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.set_null(<chrono::NaiveDate as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(<Option<i32> as ::cot::db::DatabaseField>::NULLABLE),
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("time_start"),
|
||||||
::cot::db::Identifier::new("notes"),
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(
|
::cot::db::migrations::Field::new(
|
||||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::Identifier::new("time_end"),
|
||||||
),
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("status"),
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("notes"),
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("created_at"),
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("public_notes"),
|
||||||
.set_null(
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
)
|
||||||
),
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("updated_at"),
|
::cot::db::Identifier::new("client_feedback"),
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<chrono::NaiveDateTime 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(),
|
.build(),
|
||||||
::cot::db::migrations::Operation::create_model()
|
::cot::db::migrations::Operation::create_model()
|
||||||
.table_name(::cot::db::Identifier::new("web_petting__media"))
|
.table_name(::cot::db::Identifier::new("web_petting__media"))
|
||||||
.fields(
|
.fields(&[
|
||||||
&[
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("id"),
|
||||||
::cot::db::Identifier::new("id"),
|
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.auto()
|
||||||
.auto()
|
.primary_key()
|
||||||
.primary_key()
|
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(
|
::cot::db::migrations::Field::new(
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::Identifier::new("client_id"),
|
||||||
),
|
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("client_id"),
|
.foreign_key(
|
||||||
<cot::db::ForeignKey<
|
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||||
crate::models::Client,
|
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||||
> as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||||
)
|
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||||
.foreign_key(
|
)
|
||||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
.set_null(
|
||||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
<cot::db::ForeignKey<crate::models::Client> as ::cot::db::DatabaseField>::NULLABLE,
|
||||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
),
|
||||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("visit_id"),
|
||||||
.set_null(
|
<Option<cot::db::ForeignKey<crate::models::Visit>> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<cot::db::ForeignKey<
|
)
|
||||||
crate::models::Client,
|
.foreign_key(
|
||||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
<crate::models::Visit as ::cot::db::Model>::TABLE_NAME,
|
||||||
),
|
<crate::models::Visit as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||||
::cot::db::Identifier::new("visit_id"),
|
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||||
<Option<
|
)
|
||||||
cot::db::ForeignKey<crate::models::Visit>,
|
.set_null(
|
||||||
> as ::cot::db::DatabaseField>::TYPE,
|
<Option<cot::db::ForeignKey<crate::models::Visit>> as ::cot::db::DatabaseField>::NULLABLE,
|
||||||
)
|
),
|
||||||
.foreign_key(
|
::cot::db::migrations::Field::new(
|
||||||
<crate::models::Visit as ::cot::db::Model>::TABLE_NAME,
|
::cot::db::Identifier::new("file_path"),
|
||||||
<crate::models::Visit as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
)
|
||||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
)
|
::cot::db::migrations::Field::new(
|
||||||
.set_null(
|
::cot::db::Identifier::new("file_type"),
|
||||||
<Option<
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
cot::db::ForeignKey<crate::models::Visit>,
|
)
|
||||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
),
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("caption"),
|
||||||
::cot::db::Identifier::new("file_path"),
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("status"),
|
||||||
::cot::db::Identifier::new("file_type"),
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("created_at"),
|
||||||
::cot::db::Identifier::new("caption"),
|
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.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(),
|
.build(),
|
||||||
::cot::db::migrations::Operation::create_model()
|
::cot::db::migrations::Operation::create_model()
|
||||||
.table_name(::cot::db::Identifier::new("web_petting__lead"))
|
.table_name(::cot::db::Identifier::new("web_petting__lead"))
|
||||||
.fields(
|
.fields(&[
|
||||||
&[
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::Identifier::new("id"),
|
||||||
::cot::db::Identifier::new("id"),
|
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
)
|
||||||
)
|
.auto()
|
||||||
.auto()
|
.primary_key()
|
||||||
.primary_key()
|
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
.set_null(
|
::cot::db::migrations::Field::new(
|
||||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::Identifier::new("name"),
|
||||||
),
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("name"),
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("phone"),
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("phone"),
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("email"),
|
||||||
.set_null(
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
)
|
||||||
),
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("email"),
|
::cot::db::Identifier::new("comment"),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
::cot::db::migrations::Field::new(
|
||||||
),
|
::cot::db::Identifier::new("status"),
|
||||||
::cot::db::migrations::Field::new(
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
::cot::db::Identifier::new("comment"),
|
)
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
)
|
::cot::db::migrations::Field::new(
|
||||||
.set_null(
|
::cot::db::Identifier::new("client_id"),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::TYPE,
|
||||||
),
|
)
|
||||||
::cot::db::migrations::Field::new(
|
.foreign_key(
|
||||||
::cot::db::Identifier::new("status"),
|
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||||
)
|
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||||
::cot::db::migrations::Field::new(
|
)
|
||||||
::cot::db::Identifier::new("client_id"),
|
.set_null(
|
||||||
<Option<
|
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::NULLABLE,
|
||||||
cot::db::ForeignKey<crate::models::Client>,
|
),
|
||||||
> as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::migrations::Field::new(
|
||||||
)
|
::cot::db::Identifier::new("created_at"),
|
||||||
.foreign_key(
|
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
)
|
||||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
::cot::db::Identifier::new("updated_at"),
|
||||||
)
|
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||||
.set_null(
|
)
|
||||||
<Option<
|
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
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(),
|
.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,18 +1,19 @@
|
|||||||
//! Migration: create Testimonial table
|
//! Append-only analytics events (landing visits, portal opens, media views).
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
pub(super) struct Migration;
|
pub(super) struct Migration;
|
||||||
|
|
||||||
impl ::cot::db::migrations::Migration for Migration {
|
impl ::cot::db::migrations::Migration for Migration {
|
||||||
const APP_NAME: &'static str = "web-petting";
|
const APP_NAME: &'static str = "web-petting";
|
||||||
const MIGRATION_NAME: &'static str = "m_0005_testimonials";
|
const MIGRATION_NAME: &'static str = "m_0003_analytics_event";
|
||||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||||
&[::cot::db::migrations::MigrationDependency::migration(
|
&[::cot::db::migrations::MigrationDependency::migration(
|
||||||
"web-petting",
|
"web-petting",
|
||||||
"m_0004_visit_public_notes",
|
"m_0001_initial",
|
||||||
)];
|
)];
|
||||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] =
|
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||||
&[::cot::db::migrations::Operation::create_model()
|
::cot::db::migrations::Operation::create_model()
|
||||||
.table_name(::cot::db::Identifier::new("web_petting__testimonial"))
|
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
|
||||||
.fields(&[
|
.fields(&[
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("id"),
|
::cot::db::Identifier::new("id"),
|
||||||
@@ -22,35 +23,39 @@ impl ::cot::db::migrations::Migration for Migration {
|
|||||||
.primary_key()
|
.primary_key()
|
||||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("text"),
|
::cot::db::Identifier::new("event_type"),
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("author_note"),
|
::cot::db::Identifier::new("client_id"),
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
.foreign_key(
|
||||||
::cot::db::migrations::Field::new(
|
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||||
::cot::db::Identifier::new("image_path"),
|
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||||
|
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||||
)
|
)
|
||||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
.set_null(
|
||||||
|
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::NULLABLE,
|
||||||
|
),
|
||||||
::cot::db::migrations::Field::new(
|
::cot::db::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("status"),
|
::cot::db::Identifier::new("media_id"),
|
||||||
|
<Option<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(<Option<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
|
::cot::db::migrations::Field::new(
|
||||||
|
::cot::db::Identifier::new("visitor_hash"),
|
||||||
<String as ::cot::db::DatabaseField>::TYPE,
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
.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::migrations::Field::new(
|
||||||
::cot::db::Identifier::new("created_at"),
|
::cot::db::Identifier::new("created_at"),
|
||||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
])
|
])
|
||||||
.build()];
|
.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()];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//! Add request metadata columns (user agent, referer, path) to analytics events.
|
||||||
|
|
||||||
|
#[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_analytics_event_meta";
|
||||||
|
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||||
|
&[::cot::db::migrations::MigrationDependency::migration(
|
||||||
|
"web-petting",
|
||||||
|
"m_0003_analytics_event",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||||
|
::cot::db::migrations::Operation::add_field()
|
||||||
|
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
|
||||||
|
.field(
|
||||||
|
::cot::db::migrations::Field::new(
|
||||||
|
::cot::db::Identifier::new("user_agent"),
|
||||||
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
::cot::db::migrations::Operation::add_field()
|
||||||
|
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
|
||||||
|
.field(
|
||||||
|
::cot::db::migrations::Field::new(
|
||||||
|
::cot::db::Identifier::new("referer"),
|
||||||
|
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
::cot::db::migrations::Operation::add_field()
|
||||||
|
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
|
||||||
|
.field(
|
||||||
|
::cot::db::migrations::Field::new(
|
||||||
|
::cot::db::Identifier::new("path"),
|
||||||
|
<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()];
|
|
||||||
}
|
|
||||||
+69
-1
@@ -43,6 +43,7 @@ pub enum VisitStatus {
|
|||||||
Scheduled,
|
Scheduled,
|
||||||
Completed,
|
Completed,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
Deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VisitStatus {
|
impl VisitStatus {
|
||||||
@@ -51,6 +52,7 @@ impl VisitStatus {
|
|||||||
Self::Scheduled => "scheduled",
|
Self::Scheduled => "scheduled",
|
||||||
Self::Completed => "completed",
|
Self::Completed => "completed",
|
||||||
Self::Cancelled => "cancelled",
|
Self::Cancelled => "cancelled",
|
||||||
|
Self::Deleted => "deleted",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,7 +148,7 @@ pub struct Visit {
|
|||||||
pub public_notes: Option<String>,
|
pub public_notes: Option<String>,
|
||||||
/// Feedback text from client via portal.
|
/// Feedback text from client via portal.
|
||||||
pub client_feedback: Option<String>,
|
pub client_feedback: Option<String>,
|
||||||
/// scheduled | completed | cancelled
|
/// scheduled | completed | cancelled | deleted
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: chrono::NaiveDateTime,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
pub updated_at: chrono::NaiveDateTime,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
@@ -179,6 +181,8 @@ pub struct User {
|
|||||||
pub login: String,
|
pub login: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
|
pub telegram_chat_id: Option<String>,
|
||||||
|
pub telegram_notifications: Option<bool>,
|
||||||
/// active | archived
|
/// active | archived
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: chrono::NaiveDateTime,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
@@ -202,6 +206,52 @@ pub struct Testimonial {
|
|||||||
pub created_at: chrono::NaiveDateTime,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Analytics event type.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum EventType {
|
||||||
|
/// A visit to the public landing page.
|
||||||
|
LandingView,
|
||||||
|
/// A client opening their private portal page via media token.
|
||||||
|
PortalOpen,
|
||||||
|
/// A client opening a full-size photo/video from their archive.
|
||||||
|
MediaView,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::LandingView => "landing_view",
|
||||||
|
Self::PortalOpen => "portal_open",
|
||||||
|
Self::MediaView => "media_view",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An append-only analytics event (landing visits, portal opens, media views).
|
||||||
|
///
|
||||||
|
/// Never soft-deleted; aggregated on demand for the admin statistics dashboard.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[model]
|
||||||
|
pub struct AnalyticsEvent {
|
||||||
|
#[model(primary_key)]
|
||||||
|
pub id: Auto<i64>,
|
||||||
|
/// landing_view | portal_open | media_view
|
||||||
|
pub event_type: String,
|
||||||
|
/// Client this event belongs to (portal/media events); None for landing views.
|
||||||
|
pub client_id: Option<ForeignKey<Client>>,
|
||||||
|
/// Media file this event refers to (media views only).
|
||||||
|
pub media_id: Option<i64>,
|
||||||
|
/// Anonymous first-party visitor id (random cookie value), used to count uniques.
|
||||||
|
pub visitor_hash: String,
|
||||||
|
/// Raw User-Agent string of the request (no IP is ever stored).
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
/// Referer header, if any (where the visitor came from).
|
||||||
|
pub referer: Option<String>,
|
||||||
|
/// Request path + query string (useful for landing UTM/referral params).
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
/// Global key-value settings (telegram_bot_token, telegram_chat_id, etc.).
|
/// Global key-value settings (telegram_bot_token, telegram_chat_id, etc.).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[model]
|
#[model]
|
||||||
@@ -213,3 +263,21 @@ pub struct Setting {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
pub updated_at: chrono::NaiveDateTime,
|
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,
|
||||||
|
}
|
||||||
+955
-60
File diff suppressed because it is too large.
Load diff
+28
-14
@@ -1,8 +1,8 @@
|
|||||||
use cot::db::{Database, query};
|
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.
|
/// Silently ignores errors (missing config, network issues) — notifications are best-effort.
|
||||||
pub async fn notify_new_lead(
|
pub async fn notify_new_lead(
|
||||||
db: &Database,
|
db: &Database,
|
||||||
@@ -14,10 +14,6 @@ pub async fn notify_new_lead(
|
|||||||
Some(t) if !t.is_empty() => t,
|
Some(t) if !t.is_empty() => t,
|
||||||
_ => return,
|
_ => 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}");
|
let mut text = format!("📋 Новая заявка!\n\nИмя: {name}");
|
||||||
if let Some(phone) = phone.filter(|s| !s.is_empty()) {
|
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}"));
|
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 url = format!("https://api.telegram.org/bot{token}/sendMessage");
|
||||||
let _ = reqwest::Client::new()
|
|
||||||
.post(&url)
|
for user in &users {
|
||||||
.json(&serde_json::json!({
|
if user.telegram_notifications != Some(true) {
|
||||||
"chat_id": chat_id,
|
continue;
|
||||||
"text": text,
|
}
|
||||||
}))
|
let chat_id = match &user.telegram_chat_id {
|
||||||
.send()
|
Some(id) if !id.is_empty() => id,
|
||||||
.await;
|
_ => 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> {
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use chrono_tz::Tz;
|
||||||
|
use cot::db::{Database, query};
|
||||||
|
|
||||||
|
use crate::models::Setting;
|
||||||
|
|
||||||
|
const DEFAULT_TZ: &str = "UTC";
|
||||||
|
|
||||||
|
/// Load timezone from the database settings.
|
||||||
|
pub async fn load_tz(db: &Database) -> Tz {
|
||||||
|
let key = "timezone".to_string();
|
||||||
|
let tz_str = query!(Setting, $key == key)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|s| s.value)
|
||||||
|
.unwrap_or_else(|| DEFAULT_TZ.to_string());
|
||||||
|
tz_str.parse::<Tz>().unwrap_or(Tz::UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current date+time in the configured timezone, returned as NaiveDateTime.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn now_in_tz(tz: Tz) -> chrono::NaiveDateTime {
|
||||||
|
chrono::Utc::now().with_timezone(&tz).naive_local()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today's date in the configured timezone.
|
||||||
|
pub fn today_in_tz(tz: Tz) -> chrono::NaiveDate {
|
||||||
|
chrono::Utc::now().with_timezone(&tz).date_naive()
|
||||||
|
}
|
||||||
+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 |
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Skipped minification because the original files appears to be already minified.
|
||||||
|
* Original file: /npm/chart.js@4.4.6/dist/chart.umd.js
|
||||||
|
*
|
||||||
|
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||||
|
*/
|
||||||
|
/*!
|
||||||
|
* Chart.js v4.4.6
|
||||||
|
* https://www.chartjs.org
|
||||||
|
* (c) 2024 Chart.js Contributors
|
||||||
|
* Released under the MIT License
|
||||||
|
*/
|
||||||
|
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?b(n,a,s):e[t]=g(a)}function b(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function x(t,e){return b(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?x(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function Z(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function J(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return J(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){Line truncated
|
||||||
|
/*!
|
||||||
|
* @kurkle/color v0.3.2
|
||||||
|
* https://github.com/kurkle/color#readme
|
||||||
|
* (c) 2023 Jukka Kurkela
|
||||||
|
* Released under the MIT License
|
||||||
|
*/function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
|
||||||
|
//# sourceMappingURL=chart.umd.js.map
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
{% extends "admin/layout.html" %}
|
||||||
|
{% let active_page = "analytics" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.analytics_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>{{ t.analytics_title }}</h1>
|
||||||
|
<p>{{ t.analytics_intro }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-controls"
|
||||||
|
data-labels='{
|
||||||
|
"landing_views": "{{ t.analytics_metric_landing_views }}",
|
||||||
|
"landing_unique": "{{ t.analytics_metric_landing_unique }}",
|
||||||
|
"portal_opens": "{{ t.analytics_metric_portal_opens }}",
|
||||||
|
"media_views": "{{ t.analytics_metric_media_views }}",
|
||||||
|
"no_data": "{{ t.analytics_no_data }}",
|
||||||
|
"loading": "{{ t.analytics_loading }}",
|
||||||
|
"error": "{{ t.analytics_load_error }}",
|
||||||
|
"log_empty": "{{ t.analytics_log_empty }}",
|
||||||
|
"log_end": "{{ t.analytics_log_end }}",
|
||||||
|
"visitor": "{{ t.analytics_log_visitor }}",
|
||||||
|
"landing_view": "{{ t.analytics_ev_landing_view }}",
|
||||||
|
"portal_open": "{{ t.analytics_ev_portal_open }}",
|
||||||
|
"media_view": "{{ t.analytics_ev_media_view }}",
|
||||||
|
"media_prefix": "{{ t.analytics_log_media_prefix }}"
|
||||||
|
}'>
|
||||||
|
<div class="analytics-presets">
|
||||||
|
<button type="button" class="button is-small preset-btn" data-days="7">{{ t.analytics_preset_7 }}</button>
|
||||||
|
<button type="button" class="button is-small preset-btn" data-days="30">{{ t.analytics_preset_30 }}</button>
|
||||||
|
<button type="button" class="button is-small preset-btn" data-days="90">{{ t.analytics_preset_90 }}</button>
|
||||||
|
<button type="button" class="button is-small preset-btn" data-days="365">{{ t.analytics_preset_365 }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-range">
|
||||||
|
<label>{{ t.analytics_range_from }}
|
||||||
|
<input type="date" id="anFrom" class="input is-small">
|
||||||
|
</label>
|
||||||
|
<label>{{ t.analytics_range_to }}
|
||||||
|
<input type="date" id="anTo" class="input is-small">
|
||||||
|
</label>
|
||||||
|
<label>{{ t.analytics_granularity }}
|
||||||
|
<select id="anGran" class="input is-small">
|
||||||
|
<option value="day">{{ t.analytics_gran_day }}</option>
|
||||||
|
<option value="week">{{ t.analytics_gran_week }}</option>
|
||||||
|
<option value="month">{{ t.analytics_gran_month }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-kpis" id="anKpis"></div>
|
||||||
|
|
||||||
|
<div class="analytics-chart-wrap">
|
||||||
|
<div class="analytics-chart-box">
|
||||||
|
<canvas id="anChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-tops">
|
||||||
|
<div class="analytics-top-card">
|
||||||
|
<h2>{{ t.analytics_top_clients }}</h2>
|
||||||
|
<table class="analytics-top-table" id="anTopClients">
|
||||||
|
<thead><tr><th>{{ t.analytics_top_clients }}</th><th>{{ t.analytics_count }}</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-top-card">
|
||||||
|
<h2>{{ t.analytics_top_media }}</h2>
|
||||||
|
<table class="analytics-top-table" id="anTopMedia">
|
||||||
|
<thead><tr><th>{{ t.analytics_top_media }}</th><th>{{ t.analytics_count }}</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-log-card">
|
||||||
|
<div class="analytics-log-head">
|
||||||
|
<h2>{{ t.analytics_log_title }}</h2>
|
||||||
|
<div class="analytics-log-filters">
|
||||||
|
<label class="log-chip" title="{{ t.analytics_ev_landing_view }}">
|
||||||
|
<input type="checkbox" class="log-type-toggle" data-type="landing_view">
|
||||||
|
<span>🌐 {{ t.analytics_ev_landing_short }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="log-chip" title="{{ t.analytics_ev_portal_open }}">
|
||||||
|
<input type="checkbox" class="log-type-toggle" data-type="portal_open" checked>
|
||||||
|
<span>👤 {{ t.analytics_ev_portal_short }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="log-chip" title="{{ t.analytics_ev_media_view }}">
|
||||||
|
<input type="checkbox" class="log-type-toggle" data-type="media_view" checked>
|
||||||
|
<span>🖼️ {{ t.analytics_ev_media_short }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-log" id="anLog"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.analytics-controls { display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end; margin-bottom:18px; }
|
||||||
|
.analytics-presets { display:flex; gap:6px; flex-wrap:wrap; }
|
||||||
|
.analytics-range { display:flex; gap:12px; flex-wrap:wrap; }
|
||||||
|
.analytics-range label { display:flex; flex-direction:column; font-size:.8rem; color:#666; gap:3px; }
|
||||||
|
.analytics-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:18px; }
|
||||||
|
.analytics-kpi { background:#fff; border-radius:12px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.06); border-left:4px solid var(--kpi-color,#7c6ed4); }
|
||||||
|
.analytics-kpi .kpi-value { font-size:1.7rem; font-weight:700; line-height:1.1; }
|
||||||
|
.analytics-kpi .kpi-label { font-size:.8rem; color:#777; margin-top:2px; }
|
||||||
|
.analytics-metric-toggles { display:flex; flex-wrap:wrap; gap:14px; align-items:center; margin-bottom:12px; font-size:.85rem; }
|
||||||
|
.analytics-metric-toggles .toggles-label { color:#777; font-weight:600; }
|
||||||
|
.analytics-metric-toggles label { display:flex; align-items:center; gap:5px; cursor:pointer; }
|
||||||
|
.analytics-chart-wrap { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-bottom:20px; }
|
||||||
|
.analytics-chart-box { position:relative; height:340px; width:100%; }
|
||||||
|
@media (max-width:600px) { .analytics-chart-box { height:260px; } }
|
||||||
|
.analytics-tops { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px; }
|
||||||
|
.analytics-top-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||||
|
.analytics-top-card h2 { font-size:1rem; margin:0 0 10px; }
|
||||||
|
.analytics-top-table { width:100%; border-collapse:collapse; font-size:.9rem; }
|
||||||
|
.analytics-top-table th { text-align:left; color:#888; font-weight:600; font-size:.78rem; border-bottom:1px solid #eee; padding:6px 4px; }
|
||||||
|
.analytics-top-table td { padding:6px 4px; border-bottom:1px solid #f4f4f4; }
|
||||||
|
.analytics-top-table td:last-child, .analytics-top-table th:last-child { text-align:right; width:70px; }
|
||||||
|
.analytics-top-empty { color:#aaa; padding:10px 4px; }
|
||||||
|
.an-media-link { display:flex; align-items:center; gap:8px; color:inherit; text-decoration:none; }
|
||||||
|
.an-media-link:hover { text-decoration:underline; }
|
||||||
|
.an-media-thumb { width:40px; height:40px; border-radius:6px; object-fit:cover; background:#eceaf5; flex:0 0 auto; }
|
||||||
|
.an-media-missing { width:40px; height:40px; border-radius:6px; background:#f0f0f3; display:inline-flex; align-items:center; justify-content:center; color:#bbb; flex:0 0 auto; }
|
||||||
|
|
||||||
|
.analytics-log-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-top:20px; }
|
||||||
|
.analytics-log-card h2 { font-size:1rem; margin:0; }
|
||||||
|
.analytics-log-head { display:flex; flex-wrap:wrap; gap:10px 18px; align-items:center; justify-content:space-between; margin-bottom:10px; }
|
||||||
|
.analytics-log-filters { display:flex; flex-wrap:wrap; gap:6px; }
|
||||||
|
.log-chip { display:inline-flex; align-items:center; gap:5px; padding:4px 11px; border-radius:999px; border:1px solid var(--admin-border,#e0e0e6); background:#f5f5f7; color:#8a8a95; font-size:.8rem; line-height:1.3; cursor:pointer; user-select:none; white-space:nowrap; transition:background .12s,color .12s,border-color .12s; }
|
||||||
|
.log-chip input { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
|
||||||
|
.log-chip:has(input:checked) { background:var(--admin-accent-soft,#eef0fb); border-color:#c9c6ee; color:#5b4fc4; font-weight:600; }
|
||||||
|
@media (max-width:600px) {
|
||||||
|
.analytics-log-head { gap:8px; margin-bottom:8px; }
|
||||||
|
.log-chip { padding:5px 12px; font-size:.82rem; }
|
||||||
|
}
|
||||||
|
.analytics-log { height:460px; overflow-y:auto; overflow-x:hidden; border:1px solid #f0f0f0; border-radius:8px; }
|
||||||
|
.an-log-row { display:grid; grid-template-columns:40px 1fr; gap:10px; align-items:start; padding:11px 12px; border-bottom:1px solid #f2f2f4; font-size:.85rem; }
|
||||||
|
.an-log-row:last-child { border-bottom:none; }
|
||||||
|
.an-log-figure { width:40px; height:40px; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:1.15rem; background:var(--admin-accent-soft,#eef0fb); overflow:hidden; }
|
||||||
|
.an-log-figure img { width:100%; height:100%; object-fit:cover; display:block; }
|
||||||
|
.an-log-main { min-width:0; }
|
||||||
|
.an-log-head { display:flex; flex-wrap:wrap; gap:2px 8px; align-items:baseline; }
|
||||||
|
.an-log-event { font-weight:650; }
|
||||||
|
.an-log-client { color:#7c6ed4; font-weight:600; text-decoration:none; }
|
||||||
|
.an-log-client:hover { text-decoration:underline; }
|
||||||
|
.an-log-num { color:#7c6ed4; font-weight:600; text-decoration:none; }
|
||||||
|
a.an-log-num:hover { text-decoration:underline; }
|
||||||
|
.an-log-time { color:#9a9aa6; font-size:.75rem; white-space:nowrap; margin-left:auto; }
|
||||||
|
.an-log-meta { color:#8a8a95; font-size:.76rem; margin-top:4px; display:flex; flex-direction:column; gap:2px; }
|
||||||
|
.an-log-meta .an-log-line { display:flex; flex-wrap:wrap; gap:4px 12px; }
|
||||||
|
.an-log-meta code { background:rgba(0,0,0,.045); padding:1px 5px; border-radius:4px; word-break:break-all; overflow-wrap:anywhere; max-width:100%; }
|
||||||
|
.an-log-ref { overflow-wrap:anywhere; word-break:break-word; }
|
||||||
|
.an-log-status { text-align:center; color:#aaa; padding:16px; font-size:.85rem; }
|
||||||
|
@media (max-width:600px) {
|
||||||
|
.an-log-row { grid-template-columns:34px 1fr; gap:9px; padding:10px; }
|
||||||
|
.an-log-figure { width:34px; height:34px; font-size:1rem; }
|
||||||
|
.an-log-time { flex-basis:100%; margin-left:0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="/static/chart.min.js?v={{ t.app_version() }}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var controls = document.querySelector('.analytics-controls');
|
||||||
|
var L = JSON.parse(controls.getAttribute('data-labels'));
|
||||||
|
|
||||||
|
var META = [
|
||||||
|
{ key: 'landing_views', color: '#7c6ed4', hidden: true },
|
||||||
|
{ key: 'landing_unique', color: '#00b496' },
|
||||||
|
{ key: 'portal_opens', color: '#ff8c26' },
|
||||||
|
{ key: 'media_views', color: '#ff5287' }
|
||||||
|
];
|
||||||
|
|
||||||
|
var fromInput = document.getElementById('anFrom');
|
||||||
|
var toInput = document.getElementById('anTo');
|
||||||
|
var granSelect = document.getElementById('anGran');
|
||||||
|
var kpisEl = document.getElementById('anKpis');
|
||||||
|
var chart = null;
|
||||||
|
|
||||||
|
// Draws a centered status message ("loading", "no data", error) directly on
|
||||||
|
// the chart canvas, so state changes never shift the page layout.
|
||||||
|
var messageOverlay = {
|
||||||
|
id: 'messageOverlay',
|
||||||
|
afterDraw: function (c) {
|
||||||
|
var msg = c.$message;
|
||||||
|
if (!msg) { return; }
|
||||||
|
var ctx = c.ctx;
|
||||||
|
var a = c.chartArea;
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.72)';
|
||||||
|
ctx.fillRect(a.left, a.top, a.right - a.left, a.bottom - a.top);
|
||||||
|
ctx.fillStyle = '#8a8a8a';
|
||||||
|
ctx.font = '600 14px system-ui, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(msg, (a.left + a.right) / 2, (a.top + a.bottom) / 2);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function setMessage(msg) {
|
||||||
|
if (!chart) { return; }
|
||||||
|
chart.$message = msg;
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(d) {
|
||||||
|
return d.getFullYear() + '-' +
|
||||||
|
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||||
|
String(d.getDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRange(days) {
|
||||||
|
var to = new Date();
|
||||||
|
var from = new Date();
|
||||||
|
from.setDate(from.getDate() - (days - 1));
|
||||||
|
fromInput.value = fmt(from);
|
||||||
|
toInput.value = fmt(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricLabel(key) { return L[key] || key; }
|
||||||
|
|
||||||
|
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
// Turns an ISO bucket label into a short display date:
|
||||||
|
// "2026-04-11" -> "11 Apr", "2026-04" -> "Apr 2026".
|
||||||
|
function formatLabel(raw) {
|
||||||
|
if (!raw) { return raw; }
|
||||||
|
var parts = String(raw).split('-');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return parseInt(parts[2], 10) + ' ' + MONTHS[parseInt(parts[1], 10) - 1];
|
||||||
|
}
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return MONTHS[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex, a) {
|
||||||
|
var n = parseInt(hex.slice(1), 16);
|
||||||
|
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKpis(report) {
|
||||||
|
kpisEl.replaceChildren();
|
||||||
|
META.forEach(function (m) {
|
||||||
|
var metric = report.metrics.find(function (x) { return x.key === m.key; });
|
||||||
|
var total = metric ? metric.total : 0;
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.className = 'analytics-kpi';
|
||||||
|
card.style.setProperty('--kpi-color', m.color);
|
||||||
|
var v = document.createElement('div');
|
||||||
|
v.className = 'kpi-value';
|
||||||
|
v.textContent = total.toLocaleString();
|
||||||
|
var lbl = document.createElement('div');
|
||||||
|
lbl.className = 'kpi-label';
|
||||||
|
lbl.textContent = metricLabel(m.key);
|
||||||
|
card.appendChild(v);
|
||||||
|
card.appendChild(lbl);
|
||||||
|
kpisEl.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
var ctx = document.getElementById('anChart').getContext('2d');
|
||||||
|
var datasets = META.map(function (m) {
|
||||||
|
return {
|
||||||
|
label: metricLabel(m.key),
|
||||||
|
data: [],
|
||||||
|
borderColor: m.color,
|
||||||
|
backgroundColor: hexToRgba(m.color, 0.12),
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 2,
|
||||||
|
tension: 0.25,
|
||||||
|
fill: true,
|
||||||
|
hidden: !!m.hidden,
|
||||||
|
_key: m.key
|
||||||
|
};
|
||||||
|
});
|
||||||
|
chart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: [], datasets: datasets },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'rectRounded',
|
||||||
|
boxWidth: 12,
|
||||||
|
boxHeight: 12,
|
||||||
|
padding: 12,
|
||||||
|
font: { size: 12 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: function (items) {
|
||||||
|
return items.length ? formatLabel(items[0].label) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
autoSkip: true,
|
||||||
|
maxRotation: 0,
|
||||||
|
callback: function (value) {
|
||||||
|
return formatLabel(this.getLabelForValue(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [messageOverlay]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyData(report) {
|
||||||
|
chart.data.labels = report.labels;
|
||||||
|
chart.data.datasets.forEach(function (ds) {
|
||||||
|
var metric = report.metrics.find(function (x) { return x.key === ds._key; });
|
||||||
|
ds.data = metric ? metric.values : [];
|
||||||
|
});
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTop(tableId, rows) {
|
||||||
|
var tbody = document.querySelector('#' + tableId + ' tbody');
|
||||||
|
tbody.replaceChildren();
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var td = document.createElement('td');
|
||||||
|
td.colSpan = 2;
|
||||||
|
td.className = 'analytics-top-empty';
|
||||||
|
td.textContent = L.no_data;
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var name = document.createElement('td');
|
||||||
|
name.textContent = row.label;
|
||||||
|
var cnt = document.createElement('td');
|
||||||
|
cnt.textContent = row.count.toLocaleString();
|
||||||
|
tr.appendChild(name);
|
||||||
|
tr.appendChild(cnt);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaLabel(m) {
|
||||||
|
if (!m.exists) { return '#' + m.media_id; }
|
||||||
|
var base = m.client_name || '';
|
||||||
|
if (m.caption) { return base ? base + ': ' + m.caption : m.caption; }
|
||||||
|
return base ? base + ' #' + m.media_id : '#' + m.media_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a clickable preview (opens the shared lightbox) for a media ref,
|
||||||
|
// or a placeholder if the file was deleted.
|
||||||
|
function mediaLink(m, thumbClass) {
|
||||||
|
if (m && m.exists && m.url) {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = m.url;
|
||||||
|
a.setAttribute('data-lightbox', m.file_type === 'video' ? 'video' : 'photo');
|
||||||
|
a.className = 'an-media-link';
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.className = thumbClass;
|
||||||
|
img.src = m.thumbnail_url;
|
||||||
|
img.alt = '';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
a.appendChild(img);
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.textContent = mediaLabel(m);
|
||||||
|
a.appendChild(span);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
var wrap = document.createElement('span');
|
||||||
|
wrap.className = 'an-media-link';
|
||||||
|
var ph = document.createElement('span');
|
||||||
|
ph.className = 'an-media-missing';
|
||||||
|
ph.textContent = '🗑';
|
||||||
|
wrap.appendChild(ph);
|
||||||
|
var lbl = document.createElement('span');
|
||||||
|
lbl.textContent = mediaLabel(m);
|
||||||
|
wrap.appendChild(lbl);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTopMedia(rows) {
|
||||||
|
var tbody = document.querySelector('#anTopMedia tbody');
|
||||||
|
tbody.replaceChildren();
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var td = document.createElement('td');
|
||||||
|
td.colSpan = 2;
|
||||||
|
td.className = 'analytics-top-empty';
|
||||||
|
td.textContent = L.no_data;
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.forEach(function (m) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var td1 = document.createElement('td');
|
||||||
|
td1.appendChild(mediaLink(m, 'an-media-thumb'));
|
||||||
|
var td2 = document.createElement('td');
|
||||||
|
td2.textContent = (m.count || 0).toLocaleString();
|
||||||
|
tr.appendChild(td1);
|
||||||
|
tr.appendChild(td2);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasData(report) {
|
||||||
|
return report.metrics.some(function (m) { return m.total > 0; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
var params = new URLSearchParams({
|
||||||
|
from: fromInput.value,
|
||||||
|
to: toInput.value,
|
||||||
|
granularity: granSelect.value
|
||||||
|
});
|
||||||
|
setMessage(L.loading);
|
||||||
|
fetch('/admin/analytics/data?' + params.toString(), { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (report) {
|
||||||
|
renderKpis(report);
|
||||||
|
applyData(report);
|
||||||
|
renderTop('anTopClients', report.top_clients);
|
||||||
|
renderTopMedia(report.top_media);
|
||||||
|
setMessage(hasData(report) ? '' : L.no_data);
|
||||||
|
})
|
||||||
|
.catch(function () { setMessage(L.error); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.preset-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
setRange(parseInt(btn.getAttribute('data-days'), 10));
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
fromInput.addEventListener('change', load);
|
||||||
|
toInput.addEventListener('change', load);
|
||||||
|
granSelect.addEventListener('change', load);
|
||||||
|
|
||||||
|
// --- Event log (newest first, infinite scroll of older events) ---------
|
||||||
|
var logEl = document.getElementById('anLog');
|
||||||
|
var logCursor = null;
|
||||||
|
var logLoading = false;
|
||||||
|
var logDone = false;
|
||||||
|
|
||||||
|
function logStatus(text) {
|
||||||
|
var s = document.createElement('div');
|
||||||
|
s.className = 'an-log-status';
|
||||||
|
s.textContent = text;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIcon(type) {
|
||||||
|
if (type === 'landing_view') { return '🌐'; }
|
||||||
|
if (type === 'portal_open') { return '👤'; }
|
||||||
|
if (type === 'media_view') { return '🖼️'; }
|
||||||
|
return '•';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortUa(ua) {
|
||||||
|
if (!ua) { return ''; }
|
||||||
|
var browser = '';
|
||||||
|
if (/Edg\//.test(ua)) { browser = 'Edge'; }
|
||||||
|
else if (/OPR\/|Opera/.test(ua)) { browser = 'Opera'; }
|
||||||
|
else if (/Chrome\//.test(ua)) { browser = 'Chrome'; }
|
||||||
|
else if (/Firefox\//.test(ua)) { browser = 'Firefox'; }
|
||||||
|
else if (/Safari\//.test(ua)) { browser = 'Safari'; }
|
||||||
|
var os = '';
|
||||||
|
if (/Windows/.test(ua)) { os = 'Windows'; }
|
||||||
|
else if (/iPhone|iPad|iPod/.test(ua)) { os = 'iOS'; }
|
||||||
|
else if (/Android/.test(ua)) { os = 'Android'; }
|
||||||
|
else if (/Mac OS X|Macintosh/.test(ua)) { os = 'macOS'; }
|
||||||
|
else if (/Linux/.test(ua)) { os = 'Linux'; }
|
||||||
|
return [browser, os].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaSpan(text) {
|
||||||
|
var s = document.createElement('span');
|
||||||
|
s.textContent = text;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaLine() {
|
||||||
|
var line = document.createElement('div');
|
||||||
|
line.className = 'an-log-line';
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogRow(e) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'an-log-row';
|
||||||
|
|
||||||
|
// Single leading figure: media preview (clickable) or a type emoji.
|
||||||
|
var figure;
|
||||||
|
if (e.media && e.media.exists && e.media.url && e.media.thumbnail_url) {
|
||||||
|
figure = document.createElement('a');
|
||||||
|
figure.href = e.media.url;
|
||||||
|
figure.setAttribute('data-lightbox', e.media.file_type === 'video' ? 'video' : 'photo');
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = e.media.thumbnail_url;
|
||||||
|
img.alt = '';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
figure.appendChild(img);
|
||||||
|
} else {
|
||||||
|
figure = document.createElement('div');
|
||||||
|
figure.textContent = eventIcon(e.event_type);
|
||||||
|
}
|
||||||
|
figure.className = 'an-log-figure';
|
||||||
|
row.appendChild(figure);
|
||||||
|
|
||||||
|
var main = document.createElement('div');
|
||||||
|
main.className = 'an-log-main';
|
||||||
|
|
||||||
|
var head = document.createElement('div');
|
||||||
|
head.className = 'an-log-head';
|
||||||
|
|
||||||
|
var ev = document.createElement('span');
|
||||||
|
ev.className = 'an-log-event';
|
||||||
|
if (e.event_type === 'media_view') {
|
||||||
|
ev.textContent = L.media_prefix;
|
||||||
|
head.appendChild(ev);
|
||||||
|
if (e.media && e.media.media_id != null) {
|
||||||
|
var num = document.createElement(e.client_id != null ? 'a' : 'span');
|
||||||
|
num.className = 'an-log-num';
|
||||||
|
num.textContent = '№' + e.media.media_id;
|
||||||
|
if (e.client_id != null) { num.href = '/admin/media?client_id=' + e.client_id; }
|
||||||
|
head.appendChild(num);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ev.textContent = L[e.event_type] || e.event_type;
|
||||||
|
head.appendChild(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.client_name) {
|
||||||
|
var cl;
|
||||||
|
if (e.client_id != null) {
|
||||||
|
cl = document.createElement('a');
|
||||||
|
cl.href = '/admin/clients/' + e.client_id + '/edit';
|
||||||
|
} else {
|
||||||
|
cl = document.createElement('span');
|
||||||
|
}
|
||||||
|
cl.className = 'an-log-client';
|
||||||
|
cl.textContent = e.client_name;
|
||||||
|
head.appendChild(cl);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tm = document.createElement('span');
|
||||||
|
tm.className = 'an-log-time';
|
||||||
|
tm.textContent = e.created_at;
|
||||||
|
head.appendChild(tm);
|
||||||
|
main.appendChild(head);
|
||||||
|
|
||||||
|
var meta = document.createElement('div');
|
||||||
|
meta.className = 'an-log-meta';
|
||||||
|
|
||||||
|
var line1 = metaLine();
|
||||||
|
if (e.visitor) { line1.appendChild(metaSpan(L.visitor + ': ' + e.visitor)); }
|
||||||
|
var ua = shortUa(e.user_agent);
|
||||||
|
if (ua) { line1.appendChild(metaSpan(ua)); }
|
||||||
|
if (line1.childNodes.length) { meta.appendChild(line1); }
|
||||||
|
|
||||||
|
if (e.referer) {
|
||||||
|
var line3 = metaLine();
|
||||||
|
var ref = document.createElement('span');
|
||||||
|
ref.className = 'an-log-ref';
|
||||||
|
ref.textContent = '← ' + e.referer;
|
||||||
|
line3.appendChild(ref);
|
||||||
|
meta.appendChild(line3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.childNodes.length) { main.appendChild(meta); }
|
||||||
|
|
||||||
|
row.appendChild(main);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedLogTypes() {
|
||||||
|
var types = [];
|
||||||
|
document.querySelectorAll('.log-type-toggle').forEach(function (cb) {
|
||||||
|
if (cb.checked) { types.push(cb.getAttribute('data-type')); }
|
||||||
|
});
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLog() {
|
||||||
|
logEl.replaceChildren();
|
||||||
|
logCursor = null;
|
||||||
|
logLoading = false;
|
||||||
|
logDone = false;
|
||||||
|
loadLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLog() {
|
||||||
|
if (logLoading || logDone) { return; }
|
||||||
|
var types = selectedLogTypes();
|
||||||
|
if (types.length === 0) {
|
||||||
|
logEl.replaceChildren(logStatus(L.log_empty));
|
||||||
|
logDone = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logLoading = true;
|
||||||
|
var busy = logStatus(L.loading);
|
||||||
|
logEl.appendChild(busy);
|
||||||
|
var params = new URLSearchParams({ limit: '40' });
|
||||||
|
if (logCursor) { params.set('before', logCursor); }
|
||||||
|
types.forEach(function (t) { params.append('types', t); });
|
||||||
|
fetch('/admin/analytics/events?' + params.toString(), { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
busy.remove();
|
||||||
|
(data.events || []).forEach(function (e) { logEl.appendChild(renderLogRow(e)); });
|
||||||
|
logCursor = data.next_cursor;
|
||||||
|
logLoading = false;
|
||||||
|
if (!logEl.querySelector('.an-log-row')) {
|
||||||
|
logEl.appendChild(logStatus(L.log_empty));
|
||||||
|
logDone = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!logCursor) {
|
||||||
|
logDone = true;
|
||||||
|
logEl.appendChild(logStatus(L.log_end));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep loading until the scroll container is actually scrollable.
|
||||||
|
if (logEl.scrollHeight <= logEl.clientHeight + 20) { loadLog(); }
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
busy.remove();
|
||||||
|
logLoading = false;
|
||||||
|
logEl.appendChild(logStatus(L.error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logEl.addEventListener('scroll', function () {
|
||||||
|
if (logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 40) { loadLog(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.log-type-toggle').forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', resetLog);
|
||||||
|
});
|
||||||
|
|
||||||
|
initChart();
|
||||||
|
setRange(30);
|
||||||
|
load();
|
||||||
|
loadLog();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,12 +5,17 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>{{ title }}</h1>
|
<h1>{{ t.clients_title }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-card">
|
<section class="admin-section">
|
||||||
<form method="post" action="{{ action_url }}">
|
<header class="admin-section-head">
|
||||||
<div class="field">
|
<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>
|
<label class="label">{{ t.clients_name }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="text" name="name" value="{{ client_name }}" required>
|
<input class="input" type="text" name="name" value="{{ client_name }}" required>
|
||||||
@@ -28,59 +33,68 @@
|
|||||||
<input class="input" type="email" name="email" value="{{ client_email }}">
|
<input class="input" type="email" name="email" value="{{ client_email }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field admin-field-wide">
|
||||||
<label class="label">{{ t.clients_address }}</label>
|
<label class="label">{{ t.clients_address }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="text" name="address" value="{{ client_address }}">
|
<input class="input" type="text" name="address" value="{{ client_address }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field admin-field-wide">
|
||||||
<label class="label">{{ t.clients_notes }}</label>
|
<label class="label">{{ t.clients_notes }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<textarea class="textarea" name="notes" rows="3">{{ client_notes }}</textarea>
|
<textarea class="textarea" name="notes" rows="3">{{ client_notes }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field admin-field-wide">
|
||||||
<label class="label">{{ t.clients_color }}</label>
|
<label class="label">{{ t.clients_color }}</label>
|
||||||
<div class="control" style="display:flex;align-items:center;gap:0.5rem;">
|
<div class="control color-control">
|
||||||
<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;">
|
<input type="color" name="color" value="{{ client_color }}">
|
||||||
<span class="has-text-grey is-size-7">{{ client_color }}</span>
|
<span class="has-text-grey is-size-7">{{ client_color }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field admin-field-wide">
|
||||||
<button type="submit" class="button is-primary is-fullwidth">{{ submit_label }}</button>
|
<button type="submit" class="button is-primary is-fullwidth">{{ submit_label }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% if is_edit %}
|
{% if is_edit %}
|
||||||
<hr>
|
<div class="portal-panel">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.portal_link }}</label>
|
<label class="label">{{ t.portal_link }}</label>
|
||||||
<div class="control" style="display:flex;align-items:center;gap:0.5rem;">
|
<div class="control inline-control">
|
||||||
<input class="input is-small" type="text" readonly id="portalUrl" value="" style="flex:1;">
|
<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>
|
<button type="button" class="button is-small is-info is-outlined" onclick="navigator.clipboard.writeText(document.getElementById('portalUrl').value)">📋</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:0.75rem;text-align:center;">
|
<div class="portal-qr">
|
||||||
<canvas id="qrCanvas" style="max-width:180px;"></canvas>
|
<canvas id="qrCanvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var url = window.location.origin + '/client/{{ client_token }}';
|
var base = '{{ portal_base }}' || window.location.origin;
|
||||||
|
var url = base + '/client/{{ client_token }}';
|
||||||
document.getElementById('portalUrl').value = url;
|
document.getElementById('portalUrl').value = url;
|
||||||
new QRious({ element: document.getElementById('qrCanvas'), value: url, size: 180, level: 'M' });
|
new QRious({ element: document.getElementById('qrCanvas'), value: url, size: 180, level: 'M' });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<hr>
|
<div class="danger-zone">
|
||||||
{% if client_status == "active" %}
|
{% if client_status == "active" %}
|
||||||
<form method="post" action="/admin/clients/{{ client_id }}/archive">
|
<form method="post" action="/admin/clients/{{ client_id }}/archive">
|
||||||
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
|
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else if client_status == "archived" %}
|
||||||
<form method="post" action="/admin/clients/{{ client_id }}/activate">
|
<form method="post" action="/admin/clients/{{ client_id }}/activate">
|
||||||
<button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button>
|
<button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>{{ t.clients_title }}</h1>
|
<h1>{{ t.clients_title }}</h1>
|
||||||
<div>
|
<div class="page-head-actions">
|
||||||
{% if show_all %}
|
{% if show_all %}
|
||||||
<a href="/admin/clients?lang={{ lang.code() }}" class="button is-small is-light">{{ t.filter_show_active }}</a>
|
<a href="/admin/clients?lang={{ lang.code() }}" class="button is-small is-light">{{ t.filter_show_active }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -17,19 +17,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if clients.is_empty() %}
|
{% if clients.is_empty() %}
|
||||||
<p class="has-text-grey">{{ t.clients_empty }}</p>
|
<p class="empty-state">{{ t.clients_empty }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for client in &clients %}
|
{% for client in &clients %}
|
||||||
<div class="item-card">
|
<div class="item-card">
|
||||||
<div class="item-card-header">
|
<div class="item-card-header">
|
||||||
<a href="/admin/clients/{{ client.id }}/edit?lang={{ lang.code() }}" class="name" style="text-decoration:none;color:inherit;">
|
<a href="/admin/clients/{{ client.id }}/edit?lang={{ lang.code() }}" class="name card-link">
|
||||||
<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 }}
|
<span class="color-dot" style="background:{{ client.color.as_deref().unwrap_or("#7c6ed4") }}"></span>{{ client.name }}
|
||||||
</a>
|
</a>
|
||||||
<span class="badge badge-{{ client.status }}">{{ t.client_status(&client.status) }}</span>
|
<span class="badge badge-{{ client.status }}">{{ t.client_status(&client.status) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-card-meta">
|
<div class="item-card-meta">
|
||||||
{% if let Some(phone) = client.phone.as_deref() %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if let Some(email) = client.email.as_deref() %}
|
{% if let Some(email) = client.email.as_deref() %}
|
||||||
<span>✉️ {{ email }}</span>
|
<span>✉️ {{ email }}</span>
|
||||||
|
|||||||
@@ -10,27 +10,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if today_visits.is_empty() %}
|
{% 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 %}
|
{% else %}
|
||||||
{% for tv in &today_visits %}
|
{% for tv in &today_visits %}
|
||||||
<div class="item-card">
|
<div class="item-card">
|
||||||
<div class="item-card-header">
|
<div class="item-card-header">
|
||||||
<a href="/admin/schedule/{{ tv.visit.id }}/edit?lang={{ lang.code() }}" class="name" style="text-decoration:none;color:inherit;">
|
<a href="/admin/schedule/{{ tv.visit.id }}/edit?lang={{ lang.code() }}" class="name card-link">
|
||||||
<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 }}
|
<span class="color-dot" style="background:{{ tv.client_color }}"></span>{{ tv.client_name }}
|
||||||
</a>
|
</a>
|
||||||
<span class="badge badge-visit-{{ tv.visit.status }}">{{ t.visit_status(&tv.visit.status) }}</span>
|
<span class="badge badge-visit-{{ tv.visit.status }}">{{ t.visit_status(&tv.visit.status) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-card-meta">
|
<div class="item-card-meta">
|
||||||
<span>🕐 {{ tv.visit.time_start }} — {{ tv.visit.time_end }}</span>
|
<span>🕐 {{ tv.visit.time_start }} — {{ tv.visit.time_end }}</span>
|
||||||
{% if !tv.client_phone.is_empty() %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if !tv.client_address.is_empty() %}
|
{% if !tv.client_address.is_empty() %}
|
||||||
<span>📍 {{ tv.client_address }}</span>
|
<span>📍 {{ tv.client_address }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if let Some(notes) = tv.visit.notes.as_deref() %}
|
{% 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 %}
|
{% endif %}
|
||||||
<div class="item-card-actions">
|
<div class="item-card-actions">
|
||||||
{% if tv.visit.status == "scheduled" %}
|
{% if tv.visit.status == "scheduled" %}
|
||||||
@@ -47,19 +47,37 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recent feedbacks -->
|
<!-- 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 recent_feedbacks.is_empty() %}
|
{% if feedbacks.is_empty() %}
|
||||||
<p class="has-text-grey">{{ t.dashboard_no_feedbacks }}</p>
|
<p class="empty-state">{{ t.dashboard_no_feedbacks }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for fb in &recent_feedbacks %}
|
{% for fb in &feedbacks %}
|
||||||
<div class="item-card" style="border-left:3px solid #7c6cff;">
|
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" class="item-card feedback-card">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;">
|
<div class="feedback-head">
|
||||||
<strong style="font-size:0.9rem;">{{ fb.client_name }}</strong>
|
<strong>{{ fb.client_name }}</strong>
|
||||||
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" style="color:#999;font-size:0.8rem;text-decoration:none;">{{ fb.visit_date }}</a>
|
<span class="feedback-date">{{ fb.visit_date }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:0.85rem;color:#4a4570;">{{ fb.feedback }}</div>
|
<div class="feedback-text">{{ fb.feedback }}</div>
|
||||||
</div>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if feedback_total_pages > 1 %}
|
||||||
|
<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 %}
|
||||||
|
{% for p in 1..=feedback_total_pages %}
|
||||||
|
{% if p == feedback_page %}
|
||||||
|
<span class="button is-small is-primary">{{ p }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="/admin/?lang={{ lang.code() }}&page={{ p }}" class="button is-small is-light">{{ p }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if feedback_page < feedback_total_pages %}
|
||||||
|
<a href="/admin/?lang={{ lang.code() }}&page={{ feedback_page + 1 }}" class="button is-small is-light">»</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+16
-108
@@ -4,111 +4,15 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {% block title %}{% endblock %}</title>
|
<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">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
<style>
|
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||||
: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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="admin-shell">
|
||||||
|
|
||||||
<!-- Top header -->
|
<!-- Top header -->
|
||||||
<div class="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">
|
<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/?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>
|
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>{{ t.nav_leads }}</a>
|
||||||
@@ -117,6 +21,7 @@
|
|||||||
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>{{ t.nav_media }}</a>
|
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>{{ t.nav_media }}</a>
|
||||||
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>{{ t.nav_testimonials }}</a>
|
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>{{ t.nav_testimonials }}</a>
|
||||||
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>{{ t.nav_users }}</a>
|
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>{{ t.nav_users }}</a>
|
||||||
|
<a href="/admin/analytics?lang={{ lang.code() }}" {% if active_page == "analytics" %}class="is-active"{% endif %}>{{ t.nav_analytics }}</a>
|
||||||
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>{{ t.nav_settings }}</a>
|
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>{{ t.nav_settings }}</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="top-header-right">
|
<div class="top-header-right">
|
||||||
@@ -134,28 +39,31 @@
|
|||||||
<!-- Bottom tabs (mobile) -->
|
<!-- Bottom tabs (mobile) -->
|
||||||
<nav class="bottom-tabs">
|
<nav class="bottom-tabs">
|
||||||
<a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>
|
<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/analytics?lang={{ lang.code() }}" {% if active_page == "analytics" %}class="is-active"{% endif %}>
|
||||||
|
<span class="tab-icon">📊</span><span class="tab-label">{{ t.nav_analytics }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>
|
<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>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if leads.is_empty() %}
|
{% if leads.is_empty() %}
|
||||||
<p class="has-text-grey">{{ t.leads_empty }}</p>
|
<p class="empty-state">{{ t.leads_empty }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for lead in &leads %}
|
{% for lead in &leads %}
|
||||||
<div class="item-card">
|
<div class="item-card">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="item-card-meta">
|
<div class="item-card-meta">
|
||||||
{% if let Some(phone) = lead.phone.as_deref() %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if let Some(comment) = lead.comment.as_deref() %}
|
{% if let Some(comment) = lead.comment.as_deref() %}
|
||||||
<span>💬 {{ comment }}</span>
|
<span>💬 {{ comment }}</span>
|
||||||
@@ -33,12 +33,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if lead.status == "new" || lead.status == "in_progress" %}
|
{% if lead.status == "new" || lead.status == "in_progress" %}
|
||||||
<div class="item-card-actions">
|
<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">
|
<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>
|
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+21
-12
@@ -4,27 +4,32 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
|
<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">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
<style>
|
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
.login-box { width: 100%; max-width: 380px; padding: 0 1rem; }
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
{% endif %}
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="auth-shell">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<div class="has-text-right mb-3">
|
<div class="auth-language">
|
||||||
<a href="?lang={{ lang.other().code() }}" class="has-text-grey is-size-7">{{ lang.other().label() }}</a>
|
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="has-text-centered mb-4">
|
<div class="auth-head">
|
||||||
<p class="is-size-3">🐾</p>
|
<span class="auth-icon">🐾</span>
|
||||||
<h1 class="is-size-4 has-text-weight-bold">{{ t.nav_title }}</h1>
|
<h1>{{ t.nav_title }}</h1>
|
||||||
<p class="has-text-grey">{{ t.login_title }}</p>
|
<p>{{ t.login_title }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% if let Some(err) = error.as_ref() %}
|
{% if let Some(err) = error.as_ref() %}
|
||||||
<div class="notification is-danger is-light">{{ err }}</div>
|
<div class="notification is-danger is-light">{{ err }}</div>
|
||||||
{% endif %}
|
{% 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">
|
<form method="post" action="/admin/login/submit">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.users_login }}</label>
|
<label class="label">{{ t.users_login }}</label>
|
||||||
@@ -34,8 +39,12 @@
|
|||||||
<label class="label">{{ t.users_password }}</label>
|
<label class="label">{{ t.users_password }}</label>
|
||||||
<div class="control"><input class="input" type="password" name="password" required></div>
|
<div class="control"><input class="input" type="password" name="password" required></div>
|
||||||
</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>
|
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+29
-55
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Client filter -->
|
<!-- Client filter -->
|
||||||
<div style="margin-bottom:1rem;">
|
<div class="filter-bar">
|
||||||
<div class="select is-small">
|
<div class="select is-small">
|
||||||
<select onchange="window.location.href='/admin/media?lang={{ lang.code() }}' + (this.value ? '&client_id=' + this.value : '')">
|
<select onchange="window.location.href='/admin/media?lang={{ lang.code() }}' + (this.value ? '&client_id=' + this.value : '')">
|
||||||
<option value="">{{ t.media_all_clients }}</option>
|
<option value="">{{ t.media_all_clients }}</option>
|
||||||
@@ -21,83 +21,57 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if items.is_empty() %}
|
{% if items.is_empty() %}
|
||||||
<p class="has-text-grey">{{ t.media_empty }}</p>
|
<p class="empty-state">{{ t.media_empty }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="media-grid">
|
<div class="media-grid">
|
||||||
{% for item in &items %}
|
{% for item in &items %}
|
||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
{% if item.media.file_type == "photo" %}
|
{% if item.media.file_type == "photo" %}
|
||||||
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="photo">
|
<a href="{{ item.media.url }}" data-lightbox="photo">
|
||||||
<img src="/admin/uploads/{{ item.media.id }}" alt="" loading="lazy">
|
<span class="photo-thumb media-loading-frame is-loading">
|
||||||
|
<img src="{{ item.media.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="video">
|
<a href="{{ item.media.url }}" data-lightbox="video">
|
||||||
<div class="video-thumb">🎬</div>
|
<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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="media-info">
|
<div class="media-info">
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<strong>{{ item.client_name }}</strong>
|
<strong>{{ item.client_name }}</strong>
|
||||||
{% if let Some(d) = item.visit_date.as_deref() %}
|
{% if let Some(d) = item.visit_date.as_deref() %}
|
||||||
<span style="color:#999;">{{ d }}</span>
|
<span class="feedback-date">{{ d }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if let Some(cap) = item.media.caption.as_deref() %}
|
{% 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 %}
|
{% 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>
|
<button class="button is-small is-danger is-outlined btn-sm">{{ t.media_delete }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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 %}
|
{% 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 %}
|
{% endblock %}
|
||||||
@@ -5,21 +5,27 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>{{ t.media_upload_title }}</h1>
|
<h1>{{ t.media_title }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-card">
|
<section class="admin-section">
|
||||||
<div style="margin-bottom:1rem;font-size:0.9rem;color:#666;">
|
<header class="admin-section-head">
|
||||||
<div><strong>{{ t.schedule_client }}:</strong> {{ client_name }}</div>
|
<span class="admin-section-icon" aria-hidden="true">📤</span>
|
||||||
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
|
<div>
|
||||||
</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">
|
<div class="field">
|
||||||
<label class="label">{{ t.media_choose_files }}</label>
|
<label class="label">{{ t.media_choose_files }}</label>
|
||||||
<div class="control">
|
<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>
|
</div>
|
||||||
|
<p id="fileCount" class="field-help"></p>
|
||||||
|
<div id="uploadQueue" class="upload-queue"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -29,7 +35,135 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</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 %}
|
{% endblock %}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<a href="/admin/schedule/new?lang={{ lang.code() }}" class="button is-primary is-small">+ {{ t.schedule_new }}</a>
|
<a href="/admin/schedule/new?lang={{ lang.code() }}" class="button is-primary is-small">+ {{ t.schedule_new }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-card" id="calendar-wrap" style="padding:0.5rem;">
|
<div class="form-card" id="calendar-wrap">
|
||||||
<div id="calendar"></div>
|
<div id="calendar"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -18,38 +18,6 @@
|
|||||||
{% if lang == Lang::Ru %}
|
{% if lang == Lang::Ru %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.17/locales/ru.global.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.17/locales/ru.global.min.js"></script>
|
||||||
{% endif %}
|
{% 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-bg" id="visitModal">
|
||||||
<div class="visit-modal">
|
<div class="visit-modal">
|
||||||
<h3><span class="color-dot" id="vmDot"></span><span id="vmTitle"></span></h3>
|
<h3><span class="color-dot" id="vmDot"></span><span id="vmTitle"></span></h3>
|
||||||
@@ -58,14 +26,10 @@
|
|||||||
<div id="vmAddress"></div>
|
<div id="vmAddress"></div>
|
||||||
<div id="vmAdmin"></div>
|
<div id="vmAdmin"></div>
|
||||||
<div id="vmTime"></div>
|
<div id="vmTime"></div>
|
||||||
<div id="vmNotes" style="margin-top:0.3rem;"></div>
|
<div id="vmNotes" class="mt-2"></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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,32 +45,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
const calendar = new FullCalendar.Calendar(calEl, {
|
const calendar = new FullCalendar.Calendar(calEl, {
|
||||||
locale: lang,
|
locale: lang,
|
||||||
|
timeZone: '{{ timezone }}',
|
||||||
initialView: window.innerWidth < 768 ? 'listWeek' : 'dayGridMonth',
|
initialView: window.innerWidth < 768 ? 'listWeek' : 'dayGridMonth',
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: 'prev,next today',
|
left: 'prev,next,today',
|
||||||
center: 'title',
|
center: 'title',
|
||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||||
},
|
},
|
||||||
events: '/admin/schedule/events',
|
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) {
|
eventClick: function(info) {
|
||||||
info.jsEvent.preventDefault();
|
info.jsEvent.preventDefault();
|
||||||
const ev = info.event;
|
const ev = info.event;
|
||||||
const p = ev.extendedProps;
|
const p = ev.extendedProps;
|
||||||
document.getElementById('vmDot').style.background = p.client_color || '#7c6ed4';
|
document.getElementById('vmDot').style.background = p.client_color || '#7c6ed4';
|
||||||
document.getElementById('vmTitle').textContent = p.client_name;
|
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('vmAddress').textContent = p.client_address ? ('📍 ' + p.client_address) : '';
|
||||||
document.getElementById('vmAdmin').textContent = '👤 ' + p.admin_name;
|
document.getElementById('vmAdmin').textContent = '👤 ' + p.admin_name;
|
||||||
document.getElementById('vmTime').textContent = '🕐 ' + p.time_start + ' — ' + p.time_end;
|
document.getElementById('vmTime').textContent = '🕐 ' + p.time_start + ' — ' + p.time_end;
|
||||||
document.getElementById('vmNotes').textContent = p.notes || '';
|
document.getElementById('vmNotes').textContent = p.notes || '';
|
||||||
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
|
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
|
||||||
document.getElementById('vmStatus').innerHTML = badge;
|
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('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang;
|
||||||
document.getElementById('visitModal').classList.add('is-open');
|
document.getElementById('visitModal').classList.add('is-open');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,24 +5,24 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>{{ t.schedule_edit_title }}</h1>
|
<h1>{{ t.schedule_title }}</h1>
|
||||||
</div>
|
</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">
|
<form method="post" action="/admin/schedule/{{ visit.id }}/save">
|
||||||
<!-- Client -->
|
<!-- Client -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_client }}</label>
|
<label class="label">{{ t.schedule_client }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select is-fullwidth">
|
<input class="input" type="text" value="{{ client.name }}{% if let Some(p) = client.phone.as_deref() %} ({{ p }}){% endif %}" readonly>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<!-- Time -->
|
<!-- Time -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_default_time }}</label>
|
<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="column">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="time" name="time_start" value="{{ visit.time_start }}" required>
|
<input class="input" type="time" name="time_start" value="{{ visit.time_start }}" required>
|
||||||
@@ -70,14 +70,17 @@
|
|||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_status }}</label>
|
<label class="label">{{ t.schedule_status }}</label>
|
||||||
<div class="control">
|
<input type="hidden" name="status" id="statusInput" value="{{ visit.status }}">
|
||||||
<div class="select is-fullwidth">
|
<div class="status-picker">
|
||||||
<select name="status">
|
<button type="button" class="status-btn status-btn-scheduled {% if visit.status == "scheduled" %}is-active{% endif %}" data-value="scheduled">
|
||||||
<option value="scheduled" {% if visit.status == "scheduled" %}selected{% endif %}>{{ t.visit_status_scheduled }}</option>
|
<span class="status-btn-icon">📅</span>{{ t.visit_status_scheduled }}
|
||||||
<option value="completed" {% if visit.status == "completed" %}selected{% endif %}>{{ t.visit_status_completed }}</option>
|
</button>
|
||||||
<option value="cancelled" {% if visit.status == "cancelled" %}selected{% endif %}>{{ t.visit_status_cancelled }}</option>
|
<button type="button" class="status-btn status-btn-completed {% if visit.status == "completed" %}is-active{% endif %}" data-value="completed">
|
||||||
</select>
|
<span class="status-btn-icon">✅</span>{{ t.visit_status_completed }}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,93 +100,246 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if let Some(fb) = visit.client_feedback.as_deref() %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="label">{{ t.schedule_client_feedback }}</label>
|
||||||
|
<div class="admin-note admin-note-info">{{ fb }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Media -->
|
||||||
|
<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="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="{{ 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="{{ 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>
|
||||||
|
|
||||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
||||||
</form>
|
</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 %}
|
||||||
|
|
||||||
{% if let Some(fb) = visit.client_feedback.as_deref() %}
|
<hr>
|
||||||
<div style="margin-top:1rem;">
|
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<hr style="margin:1rem 0;">
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
<a href="/admin/media/{{ visit.id }}/upload?lang={{ lang.code() }}" class="button is-info is-small is-outlined">📷 {{ t.media_upload }}</a>
|
|
||||||
</div>
|
|
||||||
{% if media.is_empty() %}
|
|
||||||
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ 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>
|
|
||||||
{% else %}
|
|
||||||
<a href="/admin/uploads/{{ m.id }}" data-lightbox="video">
|
|
||||||
<div class="video-thumb-sm">🎬</div>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if let Some(cap) = m.caption.as_deref() %}
|
|
||||||
<div class="media-cap">{{ cap }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="/admin/media/{{ m.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="text-align:center;">
|
|
||||||
<button class="button is-danger is-outlined btn-sm" style="font-size:0.7rem;padding:0.15rem 0.4rem;">{{ t.media_delete }}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<hr style="margin:1rem 0;">
|
|
||||||
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
<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>
|
<button type="submit" class="button is-danger is-outlined is-fullwidth is-small">{{ t.schedule_delete }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div class="upload-modal-bg" id="uploadModal">
|
||||||
|
<div class="upload-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>{{ t.media_upload_title }}</h3>
|
||||||
|
<button type="button" id="uploadModalClose" class="modal-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<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" 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>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<script>
|
||||||
.visit-media-grid {
|
// Status picker
|
||||||
display: grid;
|
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
btn.addEventListener('click', function() {
|
||||||
gap: 0.5rem;
|
document.querySelectorAll('.status-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
||||||
margin-bottom: 1rem;
|
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');
|
||||||
}
|
}
|
||||||
.visit-media-item {
|
|
||||||
background: #fafafa;
|
// Show selected file count
|
||||||
border-radius: 8px;
|
filesInput.addEventListener('change', function() {
|
||||||
border: 1px solid #eee;
|
var n = this.files.length;
|
||||||
overflow: hidden;
|
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 + '%';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
.visit-media-item img {
|
|
||||||
width: 100%;
|
// Submit via XHR for progress tracking
|
||||||
height: 80px;
|
form.addEventListener('submit', function(e) {
|
||||||
object-fit: cover;
|
e.preventDefault();
|
||||||
display: block;
|
if (!filesInput.files.length) return;
|
||||||
}
|
|
||||||
.visit-media-item .video-thumb-sm {
|
var data = new FormData(form);
|
||||||
width: 100%;
|
var xhr = new XMLHttpRequest();
|
||||||
height: 80px;
|
|
||||||
display: flex;
|
// Show progress bar, disable submit
|
||||||
align-items: center;
|
progress.style.display = 'block';
|
||||||
justify-content: center;
|
submitBtn.disabled = true;
|
||||||
font-size: 2rem;
|
submitBtn.textContent = '{{ t.media_upload_sending }}';
|
||||||
background: #f0f0f0;
|
statusText.textContent = '{{ t.media_upload_sending }}';
|
||||||
}
|
bar.classList.remove('is-processing');
|
||||||
.visit-media-item .media-cap {
|
bar.style.width = '0%';
|
||||||
font-size: 0.7rem;
|
percent.textContent = '0%';
|
||||||
color: #888;
|
|
||||||
padding: 0.2rem 0.4rem;
|
xhr.upload.addEventListener('progress', function(ev) {
|
||||||
white-space: nowrap;
|
if (!ev.lengthComputable) return;
|
||||||
overflow: hidden;
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
text-overflow: ellipsis;
|
bar.style.width = pct + '%';
|
||||||
}
|
percent.textContent = pct + '%';
|
||||||
.visit-media-item form {
|
updateQueue(pct + '%', pct, false);
|
||||||
padding: 0.2rem;
|
if (pct === 100) {
|
||||||
}
|
statusText.textContent = '{{ t.media_upload_processing }}';
|
||||||
</style>
|
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 %}
|
{% endblock %}
|
||||||
+279
-169
@@ -5,242 +5,352 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>{{ t.schedule_new_title }}</h1>
|
<h1>{{ t.schedule_title }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-card">
|
<section class="admin-section">
|
||||||
<form method="post" action="/admin/schedule/create" id="visitForm">
|
<header class="admin-section-head">
|
||||||
<!-- Client -->
|
<span class="admin-section-icon" aria-hidden="true">📅</span>
|
||||||
<div class="field">
|
<div><h2>{{ t.schedule_new_title }}</h2></div>
|
||||||
<label class="label">{{ t.schedule_client }}</label>
|
</header>
|
||||||
<div class="control">
|
<div class="admin-section-body">
|
||||||
<div class="select is-fullwidth">
|
<form method="post" action="/admin/schedule/create" id="visitForm">
|
||||||
<select name="client_id" required>
|
|
||||||
<option value="">—</option>
|
<!-- Client -->
|
||||||
{% for c in &clients %}
|
<div class="field">
|
||||||
<option value="{{ c.id }}">{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}</option>
|
<label class="label">{{ t.schedule_client }}</label>
|
||||||
{% endfor %}
|
<div class="control">
|
||||||
</select>
|
<div class="select is-fullwidth">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Admin -->
|
<!-- Admin -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_admin }}</label>
|
<label class="label">{{ t.schedule_admin }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select name="user_id">
|
<select name="user_id">
|
||||||
{% for u in &users %}
|
{% for u in &users %}
|
||||||
<option value="{{ u.id }}" {% if u.id.unwrap() == current_user_id %}selected{% endif %}>
|
<option value="{{ u.id }}" {% if u.id.unwrap() == current_user_id %}selected{% endif %}>
|
||||||
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
|
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Default time -->
|
<!-- Default time -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_default_time }}</label>
|
<label class="label">{{ t.schedule_default_time }}</label>
|
||||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
<div class="time-row">
|
||||||
<div class="column">
|
<div class="time-block">
|
||||||
<div class="control">
|
<span class="time-lbl">{{ t.schedule_time_start }}</span>
|
||||||
<input class="input" type="time" id="defaultStart" value="18:00">
|
<input class="input" type="time" id="defaultStart" value="18:00">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="time-sep">—</div>
|
||||||
<div class="column">
|
<div class="time-block">
|
||||||
<div class="control">
|
<span class="time-lbl">{{ t.schedule_time_end }}</span>
|
||||||
<input class="input" type="time" id="defaultEnd" value="19:00">
|
<input class="input" type="time" id="defaultEnd" value="19:00">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add individual date -->
|
<!-- Calendar -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_pick_dates }}</label>
|
<div class="cal-toolbar">
|
||||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
<button type="button" id="rangeModeBtn" class="button is-small is-outlined is-info">⇔ Выбрать диапазон</button>
|
||||||
<div class="column">
|
<button type="button" id="resetBtn" class="button is-small is-outlined is-danger" style="display:none;">✕ Сбросить</button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date range fill -->
|
<div class="sched-cal">
|
||||||
<div class="field">
|
<div class="cal-nav">
|
||||||
<label class="label is-small has-text-grey">{{ t.schedule_range_from }} — {{ t.schedule_range_to }}</label>
|
<button type="button" id="calPrev">◀</button>
|
||||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
<span id="calTitle"></span>
|
||||||
<div class="column">
|
<button type="button" id="calNext">▶</button>
|
||||||
<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>
|
</div>
|
||||||
|
<div class="cal-grid" id="calGrid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Selected days list -->
|
<!-- Selected days -->
|
||||||
<div class="field">
|
<div class="field" id="selectedSection" style="display:none;">
|
||||||
<label class="label">{{ t.schedule_selected_days }}</label>
|
<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 id="daysList"></div>
|
||||||
<p class="has-text-grey is-size-7" id="noDaysMsg">{{ t.schedule_no_days }}</p>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notes -->
|
<input type="hidden" name="days_json" id="daysJson" value="[]">
|
||||||
<div class="field">
|
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
|
||||||
<label class="label">{{ t.schedule_notes }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<textarea class="textarea" name="notes" rows="2"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden days data -->
|
</form>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<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>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const days = new Map(); // date string -> {start, end}
|
(function() {
|
||||||
const removeLabel = '{{ t.schedule_remove_day }}';
|
|
||||||
const weekdays = '{{ lang.code() }}' === 'ru'
|
const IS_RU = '{{ lang.code() }}' === 'ru';
|
||||||
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб']
|
const TZ = '{{ timezone }}';
|
||||||
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
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() {
|
function getDefaults() {
|
||||||
return {
|
return {
|
||||||
start: document.getElementById('defaultStart').value || '18:00',
|
start: document.getElementById('defaultStart').value || '18:00',
|
||||||
end: document.getElementById('defaultEnd').value || '19:00'
|
end: document.getElementById('defaultEnd').value || '19:00'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDay(dateStr) {
|
function addDay(ds) {
|
||||||
if (!dateStr || days.has(dateStr)) return;
|
if (!ds || days.has(ds)) return;
|
||||||
const def = getDefaults();
|
const def = getDefaults();
|
||||||
days.set(dateStr, { start: def.start, end: def.end });
|
days.set(ds, { start: def.start, end: def.end });
|
||||||
renderDays();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDay(dateStr) {
|
function toggleDay(ds) {
|
||||||
days.delete(dateStr);
|
if (days.has(ds)) { days.delete(ds); } else { addDay(ds); }
|
||||||
renderDays();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDays() {
|
// Получить текущую дату в нужном TZ как строку YYYY-MM-DD
|
||||||
const list = document.getElementById('daysList');
|
function tzDateStr(d) {
|
||||||
const msg = document.getElementById('noDaysMsg');
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
const btn = document.getElementById('submitBtn');
|
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
|
function fillRange(from, to) {
|
||||||
list.querySelectorAll('.day-row').forEach(el => el.remove());
|
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) {
|
if (days.size === 0) {
|
||||||
msg.style.display = '';
|
section.style.display = 'none';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
document.getElementById('daysJson').value = '[]';
|
document.getElementById('daysJson').value = '[]';
|
||||||
|
resetBtn.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msg.style.display = 'none';
|
resetBtn.style.display = '';
|
||||||
|
|
||||||
|
section.style.display = '';
|
||||||
btn.disabled = false;
|
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]) => {
|
sorted.forEach(function([ds, times]) {
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
const d = new Date(ds + 'T00:00:00');
|
||||||
const wd = weekdays[d.getDay()];
|
const dow = IS_RU
|
||||||
const label = dateStr.split('-').reverse().join('.') + ' ' + wd;
|
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'][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');
|
const row = document.createElement('div');
|
||||||
row.className = 'day-row';
|
row.className = 'day-row';
|
||||||
row.innerHTML = `
|
row.innerHTML =
|
||||||
<span class="day-date">${label}</span>
|
'<span class="day-date">' + label + '</span>' +
|
||||||
<input type="time" value="${times.start}" data-date="${dateStr}" data-field="start">
|
'<div class="day-times">' +
|
||||||
<span>—</span>
|
'<div class="time-badge-wrap">' +
|
||||||
<input type="time" value="${times.end}" data-date="${dateStr}" data-field="end">
|
'<span class="time-badge-label">' + times.start + '</span>' +
|
||||||
<button type="button" class="remove-btn" data-date="${dateStr}">${removeLabel}</button>
|
'<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);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update hidden JSON
|
list.querySelectorAll('.time-badge-input').forEach(function(inp) {
|
||||||
updateJson();
|
|
||||||
|
|
||||||
// Bind events
|
|
||||||
list.querySelectorAll('input[type="time"]').forEach(inp => {
|
|
||||||
inp.addEventListener('change', function() {
|
inp.addEventListener('change', function() {
|
||||||
const dt = this.dataset.date;
|
const d = days.get(this.dataset.date);
|
||||||
const field = this.dataset.field;
|
if (d) {
|
||||||
if (days.has(dt)) {
|
d[this.dataset.field] = this.value;
|
||||||
days.get(dt)[field] = this.value;
|
this.previousElementSibling.textContent = this.value;
|
||||||
updateJson();
|
updateJson();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.remove-btn').forEach(btn => {
|
list.querySelectorAll('.day-rm').forEach(function(b) {
|
||||||
btn.addEventListener('click', function() {
|
b.addEventListener('click', function() {
|
||||||
removeDay(this.dataset.date);
|
days.delete(this.dataset.date);
|
||||||
|
renderCal();
|
||||||
|
renderList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateJson() {
|
function updateJson() {
|
||||||
const arr = [...days.entries()].map(([date, t]) => ({
|
const arr = [...days.entries()].map(function([date, t]) {
|
||||||
date: date,
|
return { date: date, time_start: t.start, time_end: t.end };
|
||||||
time_start: t.start,
|
});
|
||||||
time_end: t.end
|
|
||||||
}));
|
|
||||||
document.getElementById('daysJson').value = JSON.stringify(arr);
|
document.getElementById('daysJson').value = JSON.stringify(arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('addDateBtn').addEventListener('click', function() {
|
// ── Navigation ───────────────────────────────────────────
|
||||||
const v = document.getElementById('pickDate').value;
|
document.getElementById('calPrev').addEventListener('click', function() {
|
||||||
addDay(v);
|
viewMonth--;
|
||||||
document.getElementById('pickDate').value = '';
|
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) {
|
// ── Range mode toggle ────────────────────────────────────
|
||||||
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('addDateBtn').click(); }
|
document.getElementById('rangeModeBtn').addEventListener('click', function() {
|
||||||
});
|
rangeMode = !rangeMode;
|
||||||
|
rangeStart = null;
|
||||||
document.getElementById('fillRangeBtn').addEventListener('click', function() {
|
if (rangeMode) {
|
||||||
const from = document.getElementById('rangeFrom').value;
|
this.classList.remove('is-outlined', 'is-info');
|
||||||
const to = document.getElementById('rangeTo').value;
|
this.classList.add('is-warning');
|
||||||
if (!from || !to || from > to) return;
|
this.textContent = '✕ Выбрать отдельные дни';
|
||||||
let cur = new Date(from + 'T00:00:00');
|
} else {
|
||||||
const end = new Date(to + 'T00:00:00');
|
this.classList.remove('is-warning');
|
||||||
while (cur <= end) {
|
this.classList.add('is-outlined', 'is-info');
|
||||||
const ds = cur.toISOString().slice(0, 10);
|
this.textContent = '⇔ Выбрать диапазон';
|
||||||
addDay(ds);
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
}
|
||||||
document.getElementById('rangeFrom').value = '';
|
renderCal();
|
||||||
document.getElementById('rangeTo').value = '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set default pick date to today
|
document.getElementById('resetBtn').addEventListener('click', function() {
|
||||||
document.getElementById('pickDate').valueAsDate = new Date();
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+362
-22
@@ -5,40 +5,380 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>{{ t.settings_title }}</h1>
|
<div>
|
||||||
|
<h1>{{ t.settings_title }}</h1>
|
||||||
|
<p>{{ t.settings_intro }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if saved %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-card">
|
<section class="admin-section" style="margin-bottom:1rem;">
|
||||||
<form method="post" action="/admin/settings/save">
|
<header class="admin-section-head">
|
||||||
<div class="field">
|
<span class="admin-section-icon" aria-hidden="true">📊</span>
|
||||||
<label class="label">{{ t.settings_telegram_bot_token }}</label>
|
<div>
|
||||||
<div class="control">
|
<h2>{{ t.analytics_summary_title }}</h2>
|
||||||
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
<p>{{ t.analytics_summary_period }}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="admin-section-body">
|
||||||
|
<div class="settings-analytics-kpis">
|
||||||
|
<div class="settings-analytics-kpi" style="--kpi-color:#7c6ed4">
|
||||||
|
<span class="kpi-value">{{ analytics_landing_views }}</span>
|
||||||
|
<span class="kpi-label">{{ t.analytics_metric_landing_views }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-analytics-kpi" style="--kpi-color:#00b496">
|
||||||
|
<span class="kpi-value">{{ analytics_landing_unique }}</span>
|
||||||
|
<span class="kpi-label">{{ t.analytics_metric_landing_unique }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-analytics-kpi" style="--kpi-color:#ff8c26">
|
||||||
|
<span class="kpi-value">{{ analytics_portal_opens }}</span>
|
||||||
|
<span class="kpi-label">{{ t.analytics_metric_portal_opens }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-analytics-kpi" style="--kpi-color:#ff5287">
|
||||||
|
<span class="kpi-value">{{ analytics_media_views }}</span>
|
||||||
|
<span class="kpi-label">{{ t.analytics_metric_media_views }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<a class="button is-light" href="/admin/analytics?lang={{ lang.code() }}">{{ t.analytics_open_dashboard }} →</a>
|
||||||
<label class="label">{{ t.settings_telegram_chat_id }}</label>
|
</div>
|
||||||
<div class="control">
|
</section>
|
||||||
<input class="input" type="text" name="telegram_chat_id" value="{% for s in &settings %}{% if s.key == "telegram_chat_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
|
||||||
|
<style>
|
||||||
|
.settings-analytics-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:14px; }
|
||||||
|
.settings-analytics-kpi { border-left:4px solid var(--kpi-color,#7c6ed4); padding:8px 14px; background:rgba(0,0,0,.02); border-radius:8px; display:flex; flex-direction:column; }
|
||||||
|
.settings-analytics-kpi .kpi-value { font-size:1.6rem; font-weight:700; line-height:1.1; }
|
||||||
|
.settings-analytics-kpi .kpi-label { font-size:.78rem; color:#777; margin-top:2px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
<div class="field">
|
</section>
|
||||||
<label class="label">{{ t.settings_contact_info }}</label>
|
|
||||||
<div class="control">
|
<section class="admin-section">
|
||||||
<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 %}">
|
<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>
|
</div>
|
||||||
<div class="field">
|
</section>
|
||||||
<label class="label">{{ t.settings_pricing_info }}</label>
|
|
||||||
<div class="control">
|
<section class="admin-section">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
|
</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>
|
||||||
|
</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>
|
<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 %}
|
{% endblock %}
|
||||||
@@ -4,23 +4,20 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {{ t.setup_title }}</title>
|
<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">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
<style>
|
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||||
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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="auth-shell">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<div class="has-text-right mb-3">
|
<div class="auth-language">
|
||||||
<a href="?lang={{ lang.other().code() }}" class="has-text-grey is-size-7">{{ lang.other().label() }}</a>
|
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="has-text-centered mb-4">
|
<div class="auth-head">
|
||||||
<p class="is-size-3">🐾</p>
|
<span class="auth-icon">🐾</span>
|
||||||
<h1 class="is-size-4 has-text-weight-bold">{{ t.nav_title }}</h1>
|
<h1>{{ t.nav_title }}</h1>
|
||||||
<p class="has-text-grey">{{ t.setup_title }}</p>
|
<p>{{ t.setup_title }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="has-text-grey has-text-centered is-size-7 mb-4">{{ t.setup_description }}</p>
|
<p class="has-text-grey has-text-centered is-size-7 mb-4">{{ t.setup_description }}</p>
|
||||||
{% if let Some(err) = error.as_ref() %}
|
{% if let Some(err) = error.as_ref() %}
|
||||||
|
|||||||
@@ -9,13 +9,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add form -->
|
<!-- Add form -->
|
||||||
<div class="form-card" style="margin-bottom:1.5rem;">
|
<section class="admin-section mb-5">
|
||||||
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:0.75rem;">{{ t.testimonials_add_title }}</h2>
|
<header class="admin-section-head">
|
||||||
<form method="post" action="/admin/testimonials/add" enctype="multipart/form-data">
|
<span class="admin-section-icon" aria-hidden="true">💬</span>
|
||||||
<div class="field">
|
<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>
|
<label class="label">{{ t.testimonials_text }} *</label>
|
||||||
<div class="control">
|
<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>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -30,27 +34,28 @@
|
|||||||
<input class="input" type="file" name="image" accept="image/*">
|
<input class="input" type="file" name="image" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- List -->
|
<!-- List -->
|
||||||
{% if testimonials.is_empty() %}
|
{% if testimonials.is_empty() %}
|
||||||
<p style="color:#888;">{{ t.testimonials_empty }}</p>
|
<p class="empty-state">{{ t.testimonials_empty }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for item in &testimonials %}
|
{% for item in &testimonials %}
|
||||||
<div class="item-card" id="card-{{ item.id.unwrap() }}">
|
<div class="item-card" id="card-{{ item.id.unwrap() }}">
|
||||||
<!-- View mode -->
|
<!-- View mode -->
|
||||||
<div class="tm-view" id="view-{{ item.id.unwrap() }}">
|
<div class="tm-view" id="view-{{ item.id.unwrap() }}">
|
||||||
<div class="item-card-header">
|
<div class="item-card-header">
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
<div class="testimonial-content">
|
||||||
{% if item.image_path.is_some() %}
|
{% if let Some(image_url) = item.image_url.as_deref() %}
|
||||||
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
<img src="{{ image_url }}" alt="" class="testimonial-avatar">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<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() %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +84,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.testimonials_text }}</label>
|
<label class="label">{{ t.testimonials_text }}</label>
|
||||||
<div class="control">
|
<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>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -90,10 +95,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if item.image_path.is_some() %}
|
{% if item.image_path.is_some() %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;">
|
<div class="testimonial-content mb-2">
|
||||||
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
{% if let Some(image_url) = item.image_url.as_deref() %}<img src="{{ image_url }}" alt="" class="testimonial-avatar">{% endif %}
|
||||||
<label style="font-size:0.85rem;cursor:pointer;color:#888;">
|
<label class="admin-check admin-check-danger">
|
||||||
<input type="checkbox" name="remove_image" value="1"> {{ t.testimonials_remove_image }}
|
<input type="checkbox" name="remove_image" value="1">
|
||||||
|
<span class="admin-check-box">✓</span>
|
||||||
|
<span>{{ t.testimonials_remove_image }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +111,7 @@
|
|||||||
<input class="input" type="file" name="image" accept="image/*">
|
<input class="input" type="file" name="image" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
</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="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>
|
<button type="button" class="button btn-sm is-light" onclick="toggleEdit({{ item.id.unwrap() }})">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+49
-32
@@ -11,12 +11,34 @@
|
|||||||
{% for user in &users %}
|
{% for user in &users %}
|
||||||
<div class="item-card">
|
<div class="item-card">
|
||||||
<div class="item-card-header">
|
<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>
|
<span class="badge badge-{{ user.status }}">{{ t.client_status(&user.status) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-card-meta">
|
<div class="item-card-meta">
|
||||||
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
|
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
|
||||||
</div>
|
</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">
|
<div class="item-card-actions">
|
||||||
{% if user.status == "active" %}
|
{% if user.status == "active" %}
|
||||||
<form method="post" action="/admin/users/{{ user.id }}/archive">
|
<form method="post" action="/admin/users/{{ user.id }}/archive">
|
||||||
@@ -31,41 +53,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="form-card" style="margin-top:1.5rem;">
|
<section class="admin-section mt-5">
|
||||||
<h2 class="is-size-5 has-text-weight-bold mb-3">{{ t.users_add_title }}</h2>
|
<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() %}
|
{% if let Some(err) = error.as_ref() %}
|
||||||
<div class="notification is-danger is-light">{{ err }}</div>
|
<div class="notification is-danger is-light">{{ err }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="/admin/users/add">
|
<form method="post" action="/admin/users/add" class="form-grid">
|
||||||
<div class="columns is-mobile">
|
<div class="field">
|
||||||
<div class="column">
|
<label class="label is-small">{{ t.users_login }}</label>
|
||||||
<div class="field">
|
<div class="control"><input class="input" type="text" name="login" required></div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="columns is-mobile">
|
<div class="field">
|
||||||
<div class="column">
|
<label class="label is-small">{{ t.users_display_name }}</label>
|
||||||
<div class="field">
|
<div class="control"><input class="input" type="text" name="display_name"></div>
|
||||||
<label class="label is-small">{{ t.users_password }}</label>
|
</div>
|
||||||
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
|
<div class="field">
|
||||||
</div>
|
<label class="label is-small">{{ t.users_password }}</label>
|
||||||
</div>
|
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
|
||||||
<div class="column">
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-small">{{ t.users_password_confirm }}</label>
|
<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 class="control"><input class="input" type="password" name="password_confirm" required minlength="4"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="admin-field-wide">
|
||||||
|
<button type="submit" class="button is-primary">{{ t.users_add_button }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="button is-primary">{{ t.users_add_button }}</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+560
-15
@@ -4,20 +4,108 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.portal_title }} — {{ client.name }}</title>
|
<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>
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
color: #2d2b55; line-height: 1.6; background: #f8f7ff;
|
color: #2d2b55; line-height: 1.6; background: #f8f7ff;
|
||||||
padding: 0 0 2rem;
|
padding: 0 0 2rem;
|
||||||
}
|
}
|
||||||
|
body.portal-overlay-open { overflow: hidden; }
|
||||||
.portal-header {
|
.portal-header {
|
||||||
background: linear-gradient(135deg, #7c6cff, #b06cff);
|
position: relative; isolation: isolate; overflow: visible; min-height: 150px;
|
||||||
color: #fff; padding: 2rem 1.5rem 1.5rem; text-align: center;
|
display: grid; place-items: center; padding: 2rem 1.5rem 1.5rem;
|
||||||
|
color: #2d2b55; text-align: center;
|
||||||
|
background: rgba(255,255,255,.38);
|
||||||
|
border-block: 1px solid rgba(124,108,255,.1);
|
||||||
}
|
}
|
||||||
|
.portal-header::after {
|
||||||
|
content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 15%, rgba(255,255,255,.7), transparent 35%),
|
||||||
|
linear-gradient(135deg, rgba(124,108,255,.06), rgba(176,108,255,.08));
|
||||||
|
}
|
||||||
|
.portal-header.has-media { color: #fff; }
|
||||||
|
.portal-header.has-media::after {
|
||||||
|
background: linear-gradient(135deg, rgba(30,25,65,.32), rgba(78,45,105,.2));
|
||||||
|
}
|
||||||
|
.portal-header-backdrop {
|
||||||
|
position: absolute; inset: 0; z-index: 0; overflow: hidden; pointer-events: none;
|
||||||
|
}
|
||||||
|
.portal-header-content {
|
||||||
|
position: absolute; left: 50%; top: 50%; z-index: 2;
|
||||||
|
width: max-content; max-width: calc(100% - 2rem);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
.portal-header.has-media .portal-header-content {
|
||||||
|
padding: .5rem 1.25rem; border-radius: 14px;
|
||||||
|
background: rgba(18,13,42,.62); box-shadow: 0 8px 32px rgba(18,13,42,.2);
|
||||||
|
text-shadow: 0 2px 12px rgba(0,0,0,.55);
|
||||||
|
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.header-media-grid {
|
||||||
|
width: 100%; height: 100%; display: grid;
|
||||||
|
grid-template-columns: repeat(var(--header-cols, 5), minmax(0, 1fr));
|
||||||
|
grid-template-rows: repeat(var(--header-rows, 2), minmax(0, 1fr));
|
||||||
|
gap: 3px; background: #d9d4ee; perspective: 900px;
|
||||||
|
}
|
||||||
|
.header-media-tile {
|
||||||
|
position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #ddd8ed;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
.header-media-tile[hidden] { display: none; }
|
||||||
|
.header-media-tile.is-flipping { animation: header-tile-flip .8s ease-in-out; }
|
||||||
|
.header-media-image { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.header-media-image.header-video-preview { position: absolute; max-width: none; object-fit: unset; }
|
||||||
|
.header-media-image.header-video-preview:not(.is-fitted) { opacity: 0; }
|
||||||
|
@keyframes header-tile-flip {
|
||||||
|
0%, 100% { transform: rotateY(0deg); }
|
||||||
|
49% { transform: rotateY(88deg); }
|
||||||
|
50% { transform: rotateY(-88deg); }
|
||||||
|
}
|
||||||
|
.header-decorations { position: absolute; inset: 0; overflow: hidden; }
|
||||||
|
.header-deco-item {
|
||||||
|
position: absolute; width: 72px; height: 72px; opacity: .25;
|
||||||
|
animation: header-icon-drift 58s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.header-deco-item svg { width: 100%; height: 100%; animation: header-icon-spin 110s linear infinite; }
|
||||||
|
.header-deco-item:nth-child(1) { left: 3%; top: 10%; color: #7c6cff; }
|
||||||
|
.header-deco-item:nth-child(2) { left: 18%; bottom: -12%; width: 88px; height: 88px; color: #ff8c26; animation-duration: 71s; animation-delay: -18s; }
|
||||||
|
.header-deco-item:nth-child(3) { left: 38%; top: -20%; width: 96px; height: 96px; color: #00a98f; animation-duration: 83s; animation-delay: -31s; }
|
||||||
|
.header-deco-item:nth-child(4) { right: 35%; bottom: -28%; width: 104px; height: 104px; color: #b06cff; animation-duration: 67s; animation-delay: -9s; }
|
||||||
|
.header-deco-item:nth-child(5) { right: 18%; top: 8%; width: 78px; height: 78px; color: #ff5287; animation-duration: 76s; animation-delay: -42s; }
|
||||||
|
.header-deco-item:nth-child(6) { right: 3%; bottom: -5%; width: 92px; height: 92px; color: #4d90e8; animation-duration: 89s; animation-delay: -25s; }
|
||||||
|
.header-deco-item:nth-child(even) svg { animation-direction: reverse; animation-duration: 135s; }
|
||||||
|
@keyframes header-icon-drift {
|
||||||
|
from { transform: translate3d(-8px, -4px, 0); }
|
||||||
|
to { transform: translate3d(14px, 10px, 0); }
|
||||||
|
}
|
||||||
|
@keyframes header-icon-spin { to { transform: rotate(360deg); } }
|
||||||
.portal-header h1 { font-size: 1.5rem; font-weight: 700; }
|
.portal-header h1 { font-size: 1.5rem; font-weight: 700; }
|
||||||
.portal-header .sub { opacity: 0.85; font-size: 0.9rem; margin-top: 0.25rem; }
|
.portal-header .sub { opacity: 0.88; font-size: 0.9rem; margin-top: 0.25rem; }
|
||||||
.container { max-width: 700px; margin: 0 auto; padding: 0 1rem; }
|
.portal-header.has-media .sub { opacity: .95; }
|
||||||
|
.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; z-index: 3;
|
||||||
|
display: grid; place-items: center; width: 42px; height: 42px;
|
||||||
|
border: 1px solid rgba(124,108,255,.2); border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,.72); color: #6255c7; font-size: 1.15rem;
|
||||||
|
box-shadow: 0 8px 24px rgba(57,44,126,.14); cursor: pointer;
|
||||||
|
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.portal-header.has-media .portal-settings { color: #fff; background: rgba(30,24,60,.34); border-color: rgba(255,255,255,.3); }
|
||||||
|
.portal-settings.mobile-only-settings { display: none; }
|
||||||
|
.portal-settings:hover { transform: translateY(-1px); }
|
||||||
|
.portal-settings:focus-visible { outline: 3px solid rgba(124,108,255,.35); outline-offset: 3px; }
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 1.15rem; font-weight: 700; margin: 1.5rem 0 0.75rem;
|
font-size: 1.15rem; font-weight: 700; margin: 1.5rem 0 0.75rem;
|
||||||
padding-bottom: 0.4rem; border-bottom: 2px solid #ede7f6;
|
padding-bottom: 0.4rem; border-bottom: 2px solid #ede7f6;
|
||||||
@@ -46,14 +134,26 @@
|
|||||||
.media-row img {
|
.media-row img {
|
||||||
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
|
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 {
|
.media-row .vid-thumb {
|
||||||
width: 80px; height: 60px; border-radius: 6px; background: #f0f0f0;
|
position: relative; width: 80px; height: 60px; border-radius: 6px;
|
||||||
display: flex; align-items: center; justify-content: center; font-size: 1.5rem;
|
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 { margin-top: 0.6rem; }
|
||||||
.feedback-form textarea {
|
.feedback-form textarea {
|
||||||
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
|
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;
|
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 textarea:focus { outline: none; border-color: #7c6cff; }
|
||||||
.feedback-form button {
|
.feedback-form button {
|
||||||
@@ -96,6 +196,102 @@
|
|||||||
font-weight: 700; min-width: 5.5rem;
|
font-weight: 700; min-width: 5.5rem;
|
||||||
}
|
}
|
||||||
.upcoming-row .up-time { color: #7a7599; }
|
.upcoming-row .up-time { color: #7a7599; }
|
||||||
|
.calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; }
|
||||||
|
.calendar-surface { 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-close { display: none; }
|
||||||
|
.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; }
|
||||||
|
.calendar-notification-settings {
|
||||||
|
position: relative; margin-top: .9rem; padding-top: .9rem;
|
||||||
|
border-top: 1px solid #ece9f5;
|
||||||
|
}
|
||||||
|
.calendar-notification-settings:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
|
||||||
|
.notification-setting-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: .75rem; margin-bottom: .65rem;
|
||||||
|
}
|
||||||
|
.notification-setting-title { font-size: .88rem; font-weight: 700; }
|
||||||
|
.notification-info-wrap { position: relative; display: inline-flex; }
|
||||||
|
.notification-info-button {
|
||||||
|
display: grid; place-items: center; width: 24px; height: 24px;
|
||||||
|
border: 1px solid #cfc9ed; border-radius: 50%; background: #f6f4ff;
|
||||||
|
color: #6558c8; font: 700 .78rem/1 Georgia, serif; cursor: help;
|
||||||
|
}
|
||||||
|
.notification-info-button:focus-visible { outline: 3px solid rgba(124,108,255,.25); outline-offset: 2px; }
|
||||||
|
.notification-help {
|
||||||
|
display: none; position: absolute; right: 0; bottom: calc(100% + .55rem); z-index: 20;
|
||||||
|
width: min(280px, calc(100vw - 3rem)); padding: .7rem .8rem; border-radius: 10px;
|
||||||
|
color: #fff; background: #302b52; box-shadow: 0 12px 32px rgba(20,18,40,.28);
|
||||||
|
font-size: .76rem; font-weight: 400; line-height: 1.45; text-align: left;
|
||||||
|
}
|
||||||
|
.notification-help::after {
|
||||||
|
content: ""; position: absolute; right: 7px; top: 100%;
|
||||||
|
border: 6px solid transparent; border-top-color: #302b52;
|
||||||
|
}
|
||||||
|
.notification-info-wrap:hover .notification-help,
|
||||||
|
.notification-info-wrap:focus-within .notification-help,
|
||||||
|
.notification-info-wrap.is-open .notification-help { display: block; }
|
||||||
|
.notification-toggle {
|
||||||
|
width: 100%; border: 0; border-radius: 9px; padding: .58rem .75rem;
|
||||||
|
color: #fff; background: #7567e8; font: inherit; font-size: .8rem;
|
||||||
|
font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.notification-toggle:disabled { cursor: not-allowed; opacity: .55; }
|
||||||
|
.notification-status { display: none; margin-top: .5rem; font-size: .74rem; line-height: 1.4; }
|
||||||
|
.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; }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.portal-header { min-height: 178px; padding: 2.2rem 4rem 1.8rem; }
|
||||||
|
.header-deco-item { opacity: .2; }
|
||||||
|
.portal-grid { display: flex; flex-direction: column; }
|
||||||
|
.visits-column { width: 100%; }
|
||||||
|
.portal-settings,
|
||||||
|
.portal-settings.mobile-only-settings {
|
||||||
|
position: fixed; right: max(1rem, env(safe-area-inset-right));
|
||||||
|
bottom: max(1rem, env(safe-area-inset-bottom)); z-index: 900;
|
||||||
|
display: grid; width: 50px; height: 50px;
|
||||||
|
color: #fff; background: rgba(88,72,190,.9); border-color: rgba(255,255,255,.45);
|
||||||
|
box-shadow: 0 10px 30px rgba(57,44,126,.28);
|
||||||
|
}
|
||||||
|
.portal-header.has-media .portal-settings { background: rgba(88,72,190,.9); }
|
||||||
|
.calendar-panel {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 1000; width: auto;
|
||||||
|
margin: 0; padding: 1rem max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
||||||
|
align-items: flex-end; justify-content: center; overflow-y: auto;
|
||||||
|
background: rgba(20,18,40,.58); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
.calendar-panel.open { display: flex; }
|
||||||
|
.calendar-surface {
|
||||||
|
width: min(440px, 100%); max-height: min(88dvh, 680px); overflow-y: auto;
|
||||||
|
margin: auto auto 0; padding: 1rem; border: 0; border-radius: 20px;
|
||||||
|
box-shadow: 0 22px 70px rgba(20,18,40,.28);
|
||||||
|
}
|
||||||
|
.calendar-head { gap: .55rem; }
|
||||||
|
.calendar-title { flex: 1; text-align: center; }
|
||||||
|
.calendar-close {
|
||||||
|
display: grid; place-items: center; margin-left: .2rem;
|
||||||
|
background: #f5f3fa !important; color: #756f8f !important;
|
||||||
|
}
|
||||||
|
.calendar-day { font-size: .84rem; }
|
||||||
|
.calendar-notification-settings { margin-top: 1rem; padding-top: 1rem; }
|
||||||
|
.notification-setting-title { font-size: .92rem; }
|
||||||
|
.notification-toggle { padding: .68rem 1rem; font-size: .86rem; }
|
||||||
|
.notification-help { font-size: .8rem; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.header-media-tile.is-flipping,
|
||||||
|
.header-deco-item,
|
||||||
|
.header-deco-item svg { animation: none !important; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -104,9 +300,34 @@
|
|||||||
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
|
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="portal-header">
|
<div class="portal-header{% if !header_media.is_empty() %} has-media{% endif %}">
|
||||||
<h1>{{ t.portal_title }}</h1>
|
<div class="portal-header-backdrop" aria-hidden="true">
|
||||||
<div class="sub">{{ client.name }}</div>
|
{% if header_media.is_empty() %}
|
||||||
|
<div class="header-decorations">
|
||||||
|
<span class="header-deco-item"><svg viewBox="0 0 80 80"><path d="M20 15 10 2 18 18M60 15 70 2 62 18M40 70C18 70 8 52 8 38 8 20 22 8 40 8s32 12 32 30c0 14-10 32-32 32Z" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="28" cy="36" r="3.5" fill="currentColor"/><circle cx="52" cy="36" r="3.5" fill="currentColor"/><ellipse cx="40" cy="48" rx="4" ry="2.5" fill="currentColor"/><path d="M36 48q-4 6-8 4m16-4q4 6 8 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||||
|
<span class="header-deco-item"><svg viewBox="0 0 80 50"><path d="M8 18q0 28 32 28t32-28Z" fill="currentColor" opacity=".2"/><ellipse cx="40" cy="18" rx="34" ry="10" fill="none" stroke="currentColor" stroke-width="3"/><path d="M8 18q0 28 32 28t32-28" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="header-deco-item"><svg viewBox="0 0 60 70"><ellipse cx="30" cy="46" rx="16" ry="18" fill="currentColor" opacity=".55"/><ellipse cx="14" cy="22" rx="8" ry="10" fill="currentColor" opacity=".55" transform="rotate(-10 14 22)"/><ellipse cx="46" cy="22" rx="8" ry="10" fill="currentColor" opacity=".55" transform="rotate(10 46 22)"/><ellipse cx="24" cy="8" rx="6" ry="8" fill="currentColor" opacity=".55"/><ellipse cx="38" cy="8" rx="6" ry="8" fill="currentColor" opacity=".55"/></svg></span>
|
||||||
|
<span class="header-deco-item"><svg viewBox="0 0 80 40"><path d="M60 20Q48 2 28 8 10 14 10 20t18 12q20 6 32-12Z" fill="currentColor" opacity=".25" stroke="currentColor" stroke-width="2.5"/><path d="m60 20 14-14v28Z" fill="currentColor" opacity=".25" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><circle cx="24" cy="18" r="2.5" fill="currentColor"/></svg></span>
|
||||||
|
<span class="header-deco-item"><svg viewBox="0 0 60 55"><path d="M30 50C10 35 0 22 0 14 0 5 7 0 15 0c6 0 11 3 15 9 4-6 9-9 15-9 8 0 15 5 15 14 0 8-10 21-30 36Z" fill="currentColor" opacity=".5"/></svg></span>
|
||||||
|
<span class="header-deco-item"><svg viewBox="0 0 70 50"><ellipse cx="32" cy="28" rx="22" ry="16" fill="currentColor" opacity=".2"/><ellipse cx="32" cy="28" rx="22" ry="16" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M12 22Q2 10 10 6m2 28Q2 40 6 46" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/><circle cx="22" cy="24" r="2" fill="currentColor"/><path d="M54 28q14-10 10 4-4 8-8-2" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg></span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="header-media-grid" data-header-gallery>
|
||||||
|
{% for m in &header_media %}
|
||||||
|
<div class="header-media-tile" data-media-src="{{ m.thumbnail_url }}" data-media-kind="{{ m.file_type }}">
|
||||||
|
<img class="header-media-image{% if m.file_type == "video" %} header-video-preview{% endif %}" src="{{ m.thumbnail_url }}" alt="">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="portal-header-content">
|
||||||
|
<h1>{{ t.portal_title }}</h1>
|
||||||
|
<div class="sub">{{ client.name }}</div>
|
||||||
|
</div>
|
||||||
|
{% if show_portal_settings %}
|
||||||
|
<button class="portal-settings mobile-only-settings" type="button" onclick="openPortalSettings()" aria-label="{{ t.portal_calendar }}" aria-expanded="false">📅</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -115,13 +336,15 @@
|
|||||||
<div class="success-msg">{{ t.portal_feedback_thanks }}</div>
|
<div class="success-msg">{{ t.portal_feedback_thanks }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="portal-grid">
|
||||||
|
<main class="visits-column">
|
||||||
<!-- Past visits with media first -->
|
<!-- Past visits with media first -->
|
||||||
<h2 class="section-title">{{ t.portal_past }}</h2>
|
<h2 class="section-title">{{ t.portal_past }}</h2>
|
||||||
{% if past.is_empty() %}
|
{% if past.is_empty() %}
|
||||||
<p class="empty-msg">{{ t.portal_no_past }}</p>
|
<p class="empty-msg">{{ t.portal_no_past }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for pv in &past %}
|
{% for pv in &past %}
|
||||||
<div class="visit-card">
|
<div class="visit-card" id="visit-{{ pv.visit.id.unwrap() }}">
|
||||||
<div class="visit-card-head">
|
<div class="visit-card-head">
|
||||||
<span class="date">{{ pv.visit.visit_date }}</span>
|
<span class="date">{{ pv.visit.visit_date }}</span>
|
||||||
<span class="badge-sm badge-{{ pv.visit.status }}">
|
<span class="badge-sm badge-{{ pv.visit.status }}">
|
||||||
@@ -141,12 +364,19 @@
|
|||||||
<div class="media-row">
|
<div class="media-row">
|
||||||
{% for m in &pv.media %}
|
{% for m in &pv.media %}
|
||||||
{% if m.file_type == "photo" %}
|
{% if m.file_type == "photo" %}
|
||||||
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="photo">
|
<a href="{{ m.url }}" data-lightbox="photo">
|
||||||
<img src="/client/{{ client.media_token }}/media/{{ m.id }}" alt="" loading="lazy">
|
<span class="media-thumb-frame media-loading-frame is-loading">
|
||||||
|
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="video">
|
<a href="{{ m.url }}" data-lightbox="video">
|
||||||
<div class="vid-thumb">🎬</div>
|
<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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -163,6 +393,9 @@
|
|||||||
</div>
|
</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">
|
<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>
|
<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;">
|
<div style="display:flex;gap:0.4rem;">
|
||||||
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
||||||
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})">✕</button>
|
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})">✕</button>
|
||||||
@@ -171,12 +404,22 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<form class="feedback-form" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
|
<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>
|
<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>
|
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Compact upcoming schedule -->
|
<!-- Compact upcoming schedule -->
|
||||||
@@ -186,15 +429,204 @@
|
|||||||
{% for pv in &upcoming %}
|
{% for pv in &upcoming %}
|
||||||
<div class="upcoming-row">
|
<div class="upcoming-row">
|
||||||
<span class="up-date">{{ pv.visit.visit_date }}</span>
|
<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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% if show_portal_settings %}
|
||||||
|
<aside class="calendar-panel" id="calendarPanel" onclick="if(event.target===this) closePortalSettings()">
|
||||||
|
<div class="calendar-surface">
|
||||||
|
{% if !calendar_months.is_empty() %}
|
||||||
|
<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>
|
||||||
|
<button class="calendar-close" type="button" onclick="closePortalSettings()" aria-label="{{ t.portal_settings }}">×</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>
|
||||||
|
{% else %}
|
||||||
|
<div class="calendar-head">
|
||||||
|
<span class="calendar-title">{{ t.portal_settings }}</span>
|
||||||
|
<button class="calendar-close" type="button" onclick="closePortalSettings()" aria-label="{{ t.portal_settings }}">×</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if notifications_enabled %}
|
||||||
|
<section class="calendar-notification-settings" aria-labelledby="notificationSettingTitle">
|
||||||
|
<div class="notification-setting-head">
|
||||||
|
<h2 class="notification-setting-title" id="notificationSettingTitle">{{ t.portal_notifications }}</h2>
|
||||||
|
<span class="notification-info-wrap">
|
||||||
|
<button class="notification-info-button" type="button" onclick="toggleNotificationHelp(event)" aria-label="{{ t.portal_notifications_text }}" aria-expanded="false" aria-describedby="notificationHelp">i</button>
|
||||||
|
<span class="notification-help" id="notificationHelp" role="tooltip">{{ t.portal_notifications_text }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="notification-toggle" type="button" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
|
||||||
|
<p class="notification-status" id="notificationStatus"></p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
(function() {
|
||||||
|
var grid = document.querySelector('[data-header-gallery]');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
var tiles = Array.from(grid.querySelectorAll('.header-media-tile'));
|
||||||
|
var sources = tiles.map(function(tile) {
|
||||||
|
return { src: tile.dataset.mediaSrc, kind: tile.dataset.mediaKind };
|
||||||
|
});
|
||||||
|
var mobile = window.matchMedia('(max-width: 800px)');
|
||||||
|
|
||||||
|
function resetHeaderImageBox(image) {
|
||||||
|
['width', 'height', 'left', 'top'].forEach(function(property) {
|
||||||
|
image.style.removeProperty(property);
|
||||||
|
});
|
||||||
|
image.classList.remove('is-fitted');
|
||||||
|
}
|
||||||
|
function fitHeaderMediaImage(image) {
|
||||||
|
resetHeaderImageBox(image);
|
||||||
|
if (!image.classList.contains('header-video-preview')) return;
|
||||||
|
|
||||||
|
var tile = image.closest('.header-media-tile');
|
||||||
|
if (!tile || !image.naturalWidth || !image.naturalHeight || !tile.clientWidth || !tile.clientHeight) return;
|
||||||
|
|
||||||
|
var frameWidth = image.naturalWidth / 4;
|
||||||
|
var scale = Math.max(tile.clientWidth / frameWidth, tile.clientHeight / image.naturalHeight);
|
||||||
|
var renderedFrameWidth = frameWidth * scale;
|
||||||
|
var renderedHeight = image.naturalHeight * scale;
|
||||||
|
image.style.width = image.naturalWidth * scale + 'px';
|
||||||
|
image.style.height = renderedHeight + 'px';
|
||||||
|
image.style.left = (tile.clientWidth - renderedFrameWidth) / 2 + 'px';
|
||||||
|
image.style.top = (tile.clientHeight - renderedHeight) / 2 + 'px';
|
||||||
|
image.classList.add('is-fitted');
|
||||||
|
}
|
||||||
|
function configureGrid() {
|
||||||
|
var limit = mobile.matches ? 6 : 10;
|
||||||
|
var visible = Math.min(limit, tiles.length);
|
||||||
|
var columns = visible === 1 ? 1 : Math.min(mobile.matches ? 3 : 5, visible);
|
||||||
|
var rows = Math.max(1, Math.ceil(visible / columns));
|
||||||
|
grid.style.setProperty('--header-cols', columns);
|
||||||
|
grid.style.setProperty('--header-rows', rows);
|
||||||
|
tiles.forEach(function(tile, index) { tile.hidden = index >= visible; });
|
||||||
|
window.requestAnimationFrame(function() {
|
||||||
|
tiles.forEach(function(tile) { fitHeaderMediaImage(tile.querySelector('img')); });
|
||||||
|
});
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleCount = configureGrid();
|
||||||
|
tiles.forEach(function(tile) {
|
||||||
|
var image = tile.querySelector('img');
|
||||||
|
image.addEventListener('load', function() { fitHeaderMediaImage(image); });
|
||||||
|
if (image.complete) fitHeaderMediaImage(image);
|
||||||
|
});
|
||||||
|
mobile.addEventListener && mobile.addEventListener('change', configureGrid);
|
||||||
|
var resizeFrame;
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
window.cancelAnimationFrame(resizeFrame);
|
||||||
|
resizeFrame = window.requestAnimationFrame(function() {
|
||||||
|
tiles.forEach(function(tile) { fitHeaderMediaImage(tile.querySelector('img')); });
|
||||||
|
});
|
||||||
|
}, { passive: true });
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || sources.length <= visibleCount) return;
|
||||||
|
|
||||||
|
var nextSource = visibleCount;
|
||||||
|
function chooseSource(tile) {
|
||||||
|
var source = sources[nextSource % sources.length];
|
||||||
|
nextSource += 1;
|
||||||
|
if (source.src === tile.dataset.mediaSrc && sources.length > 1) {
|
||||||
|
source = sources[nextSource % sources.length];
|
||||||
|
nextSource += 1;
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
function scheduleSwap(tile, index, initial) {
|
||||||
|
var delay = (initial ? 10000 : 19000) + index * 1700 + Math.floor(Math.random() * 6500);
|
||||||
|
window.setTimeout(function() {
|
||||||
|
if (document.hidden || tile.hidden) {
|
||||||
|
scheduleSwap(tile, index, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var source = chooseSource(tile);
|
||||||
|
var image = tile.querySelector('img');
|
||||||
|
tile.classList.add('is-flipping');
|
||||||
|
window.setTimeout(function() {
|
||||||
|
image.classList.toggle('header-video-preview', source.kind === 'video');
|
||||||
|
resetHeaderImageBox(image);
|
||||||
|
image.src = source.src;
|
||||||
|
tile.dataset.mediaSrc = source.src;
|
||||||
|
tile.dataset.mediaKind = source.kind;
|
||||||
|
if (image.complete) fitHeaderMediaImage(image);
|
||||||
|
}, 390);
|
||||||
|
window.setTimeout(function() {
|
||||||
|
tile.classList.remove('is-flipping');
|
||||||
|
scheduleSwap(tile, index, false);
|
||||||
|
}, 820);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
tiles.slice(0, visibleCount).forEach(function(tile, index) {
|
||||||
|
scheduleSwap(tile, index, true);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function setSettingsButtonExpanded(expanded) {
|
||||||
|
var button = document.querySelector('.portal-settings');
|
||||||
|
if (button) button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
function openPortalSettings() {
|
||||||
|
var calendar = document.getElementById('calendarPanel');
|
||||||
|
if (window.matchMedia('(max-width: 800px)').matches && calendar) {
|
||||||
|
calendar.classList.add('open');
|
||||||
|
document.body.classList.add('portal-overlay-open');
|
||||||
|
setSettingsButtonExpanded(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closePortalSettings() {
|
||||||
|
var calendar = document.getElementById('calendarPanel');
|
||||||
|
if (calendar) calendar.classList.remove('open');
|
||||||
|
document.body.classList.remove('portal-overlay-open');
|
||||||
|
setSettingsButtonExpanded(false);
|
||||||
|
}
|
||||||
|
function toggleNotificationHelp(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
var button = event.currentTarget;
|
||||||
|
var wrap = button.closest('.notification-info-wrap');
|
||||||
|
var open = !wrap.classList.contains('is-open');
|
||||||
|
wrap.classList.toggle('is-open', open);
|
||||||
|
button.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
function closeNotificationHelp() {
|
||||||
|
var wrap = document.querySelector('.notification-info-wrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.classList.remove('is-open');
|
||||||
|
var button = wrap.querySelector('.notification-info-button');
|
||||||
|
if (button) button.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
if (!event.target.closest('.notification-info-wrap')) closeNotificationHelp();
|
||||||
|
});
|
||||||
function showFbEdit(id) {
|
function showFbEdit(id) {
|
||||||
document.getElementById('fb-view-' + id).style.display = 'none';
|
document.getElementById('fb-view-' + id).style.display = 'none';
|
||||||
document.getElementById('fb-form-' + id).style.display = '';
|
document.getElementById('fb-form-' + id).style.display = '';
|
||||||
@@ -203,7 +635,120 @@ function hideFbEdit(id) {
|
|||||||
document.getElementById('fb-form-' + id).style.display = 'none';
|
document.getElementById('fb-form-' + id).style.display = 'none';
|
||||||
document.getElementById('fb-view-' + id).style.display = '';
|
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 = 'block';
|
||||||
|
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 }}';
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
status.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
var permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
showStatus('{{ t.portal_notifications_denied }}', true);
|
||||||
|
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 %}
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
closeNotificationHelp();
|
||||||
|
closePortalSettings();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% include "partials/lightbox.html" %}
|
{% include "partials/lightbox.html" %}
|
||||||
|
{% include "partials/cookie_banner.html" %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+79
-10
@@ -5,11 +5,30 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="{{ t.landing_meta_description }}">
|
<meta name="description" content="{{ t.landing_meta_description }}">
|
||||||
<title>{{ t.nav_title }} — {{ t.landing_hero_title }}</title>
|
<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 -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
<meta property="og:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||||
<meta property="og:description" content="{{ t.landing_meta_description }}">
|
<meta property="og:description" content="{{ t.landing_meta_description }}">
|
||||||
<meta property="og:type" content="website">
|
<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 -->
|
<!-- Structured Data -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
@@ -19,10 +38,22 @@
|
|||||||
"name": "{{ t.nav_title }}",
|
"name": "{{ t.nav_title }}",
|
||||||
"description": "{{ t.landing_meta_description }}",
|
"description": "{{ t.landing_meta_description }}",
|
||||||
"serviceType": "Pet Sitting",
|
"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>
|
</script>
|
||||||
|
|
||||||
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Reset & Base ── */
|
/* ── Reset & Base ── */
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -96,6 +127,25 @@
|
|||||||
box-shadow: 0 4px 20px rgba(124,108,255,0.35);
|
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-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(124,108,255,0.45); }
|
||||||
|
.hero { position: relative; overflow: clip; }
|
||||||
|
.hero-photo {
|
||||||
|
position: absolute; z-index: 0;
|
||||||
|
width: 420px; height: 420px;
|
||||||
|
border-radius: 50%;
|
||||||
|
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-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-emoji { font-size: 4rem; margin-bottom: 1rem; display: block; }
|
||||||
.hero-desc {
|
.hero-desc {
|
||||||
font-size: clamp(0.9rem, 2vw, 1.05rem);
|
font-size: clamp(0.9rem, 2vw, 1.05rem);
|
||||||
@@ -118,6 +168,7 @@
|
|||||||
border-radius: 18px; padding: 1.75rem;
|
border-radius: 18px; padding: 1.75rem;
|
||||||
border: 1px solid rgba(180,170,220,0.2);
|
border: 1px solid rgba(180,170,220,0.2);
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||||
}
|
}
|
||||||
.testimonial-card:hover {
|
.testimonial-card:hover {
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
@@ -147,6 +198,7 @@
|
|||||||
border-radius: 18px; padding: 2rem 2.5rem;
|
border-radius: 18px; padding: 2rem 2.5rem;
|
||||||
border: 1px solid rgba(180,170,220,0.2);
|
border: 1px solid rgba(180,170,220,0.2);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||||
}
|
}
|
||||||
.pricing-text {
|
.pricing-text {
|
||||||
font-size: clamp(1.1rem, 2.5vw, 1.3rem);
|
font-size: clamp(1.1rem, 2.5vw, 1.3rem);
|
||||||
@@ -177,12 +229,13 @@
|
|||||||
border-radius: 18px; padding: 2rem 1.75rem;
|
border-radius: 18px; padding: 2rem 1.75rem;
|
||||||
border: 1px solid rgba(180,170,220,0.2);
|
border: 1px solid rgba(180,170,220,0.2);
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||||
}
|
}
|
||||||
.service-card:hover {
|
.service-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 12px 36px rgba(124,108,255,0.12);
|
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 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; }
|
.service-card p { color: #5a5680; font-size: 0.95rem; line-height: 1.6; }
|
||||||
|
|
||||||
@@ -210,6 +263,7 @@
|
|||||||
border-radius: 22px; padding: 2.5rem 2rem;
|
border-radius: 22px; padding: 2.5rem 2rem;
|
||||||
box-shadow: 0 8px 40px rgba(124,108,255,0.12);
|
box-shadow: 0 8px 40px rgba(124,108,255,0.12);
|
||||||
border: 1px solid rgba(180,170,220,0.25);
|
border: 1px solid rgba(180,170,220,0.25);
|
||||||
|
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||||
}
|
}
|
||||||
.form-wrapper h2 {
|
.form-wrapper h2 {
|
||||||
text-align: center; font-size: 1.6rem; font-weight: 800;
|
text-align: center; font-size: 1.6rem; font-weight: 800;
|
||||||
@@ -255,10 +309,14 @@
|
|||||||
border-top: 1px solid rgba(180,170,220,0.15);
|
border-top: 1px solid rgba(180,170,220,0.15);
|
||||||
background: rgba(255,255,255,0.3);
|
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 ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.hero { padding: 6.5rem 1rem 3rem; }
|
.hero { padding: 6.5rem 1rem 10rem; }
|
||||||
.section { padding: 3rem 1rem; }
|
.section { padding: 3rem 1rem; }
|
||||||
.form-section { padding: 3rem 1rem; }
|
.form-section { padding: 3rem 1rem; }
|
||||||
.form-wrapper { padding: 1.75rem 1.25rem; }
|
.form-wrapper { padding: 1.75rem 1.25rem; }
|
||||||
@@ -286,11 +344,15 @@
|
|||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<span class="hero-emoji" role="img" aria-label="pets">🐱🐹🦎</span>
|
<img class="hero-photo hero-photo-tr" src="/static/cat_up_right.png" alt="">
|
||||||
<h1>{{ t.landing_hero_title }}</h1>
|
<img class="hero-photo hero-photo-bl" src="/static/cat_bottom_left.png" alt="">
|
||||||
<p>{{ t.landing_hero_subtitle }}</p>
|
<div class="hero-content">
|
||||||
<p class="hero-desc">{{ t.landing_hero_description }}</p>
|
<span class="hero-emoji" role="img" aria-label="pets">🐱🐹🦎</span>
|
||||||
<a href="#form" class="hero-cta">{{ t.landing_hero_cta }}</a>
|
<h1>{{ t.landing_hero_title }}</h1>
|
||||||
|
<p>{{ t.landing_hero_subtitle }}</p>
|
||||||
|
<p class="hero-desc">{{ t.landing_hero_description }}</p>
|
||||||
|
<a href="#form" class="hero-cta">{{ t.landing_hero_cta }}</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Services -->
|
<!-- Services -->
|
||||||
@@ -364,8 +426,8 @@
|
|||||||
<div class="testimonial-text">{{ item.text }}</div>
|
<div class="testimonial-text">{{ item.text }}</div>
|
||||||
{% if item.image_path.is_some() || item.author_note.is_some() %}
|
{% if item.image_path.is_some() || item.author_note.is_some() %}
|
||||||
<div class="testimonial-footer">
|
<div class="testimonial-footer">
|
||||||
{% if item.image_path.is_some() %}
|
{% if let Some(image_url) = item.image_url.as_deref() %}
|
||||||
<img class="testimonial-avatar" src="/testimonial-image/{{ item.id.unwrap() }}" alt="">
|
<img class="testimonial-avatar" src="{{ image_url }}" alt="">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if let Some(note) = item.author_note.as_deref() %}
|
{% if let Some(note) = item.author_note.as_deref() %}
|
||||||
<span class="testimonial-note">{{ note }}</span>
|
<span class="testimonial-note">{{ note }}</span>
|
||||||
@@ -406,6 +468,9 @@
|
|||||||
<input type="checkbox" id="consent" name="consent" required style="margin-top:0.2rem;width:auto;flex-shrink:0;">
|
<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>
|
<label for="consent" style="font-size:0.82rem;font-weight:400;color:#7a7599;cursor:pointer;display:inline;">{{ t.landing_form_consent }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-size="compact" style="margin-bottom:1.25rem;"></div>
|
||||||
|
{% endif %}
|
||||||
<button type="submit" class="form-submit">{{ t.landing_form_submit }}</button>
|
<button type="submit" class="form-submit">{{ t.landing_form_submit }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% if !contact_info.is_empty() %}
|
{% if !contact_info.is_empty() %}
|
||||||
@@ -419,6 +484,9 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="site-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>{{ t.landing_footer_text }}</p>
|
||||||
<p style="margin-top:0.4rem;">© 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
|
<p style="margin-top:0.4rem;">© 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -501,5 +569,6 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{% include "partials/cookie_banner.html" %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<div class="cookie-banner" id="cookieBanner">
|
||||||
|
<p class="cookie-banner-text">
|
||||||
|
{{ t.cookie_banner_text }}
|
||||||
|
<a href="/privacy?lang={{ lang.code() }}">{{ t.cookie_privacy_link }}</a>
|
||||||
|
</p>
|
||||||
|
<button type="button" class="cookie-banner-accept" id="cookieAccept">{{ t.cookie_banner_accept }}</button>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.cookie-banner {
|
||||||
|
display: none;
|
||||||
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 300;
|
||||||
|
flex-wrap: wrap; gap: .75rem 1.25rem; align-items: center; justify-content: center;
|
||||||
|
padding: .85rem 1.1rem;
|
||||||
|
background: rgba(26, 26, 46, .95); color: #fff;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, .18);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.cookie-banner.is-open { display: flex; }
|
||||||
|
.cookie-banner-text { margin: 0; font-size: .88rem; line-height: 1.45; max-width: 720px; }
|
||||||
|
.cookie-banner-text a { color: #b9b2ff; text-decoration: underline; }
|
||||||
|
.cookie-banner-accept {
|
||||||
|
flex: 0 0 auto; border: none; cursor: pointer;
|
||||||
|
padding: .6rem 1.6rem; border-radius: 10px;
|
||||||
|
background: #6c63ff; color: #fff; font-weight: 700; font-size: .9rem;
|
||||||
|
transition: background .18s, transform .18s;
|
||||||
|
}
|
||||||
|
.cookie-banner-accept:hover { background: #5a52d5; transform: translateY(-1px); }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.cookie-banner.is-open { flex-direction: column; text-align: center; padding: 1rem; gap: .7rem; }
|
||||||
|
.cookie-banner-accept { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var KEY = 'cookie_consent';
|
||||||
|
var banner = document.getElementById('cookieBanner');
|
||||||
|
if (!banner) { return; }
|
||||||
|
var accepted;
|
||||||
|
try { accepted = window.localStorage.getItem(KEY) === '1'; } catch (e) { accepted = false; }
|
||||||
|
if (accepted) { return; }
|
||||||
|
banner.classList.add('is-open');
|
||||||
|
document.getElementById('cookieAccept').addEventListener('click', function () {
|
||||||
|
try { window.localStorage.setItem(KEY, '1'); } catch (e) {}
|
||||||
|
banner.classList.remove('is-open');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -1,49 +1,164 @@
|
|||||||
<div class="lightbox-overlay" id="lightbox" onclick="closeLightbox(event)">
|
<div class="lightbox-overlay" id="lightbox" onclick="closeLightbox(event)">
|
||||||
<button class="lightbox-close" onclick="closeLightbox(event)">×</button>
|
<button class="lightbox-close" type="button" onclick="closeLightbox(event)">×</button>
|
||||||
<img id="lightboxImg" src="" alt="">
|
<div class="lightbox-stage" id="lightboxStage">
|
||||||
<video id="lightboxVideo" controls style="display:none;"></video>
|
<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>
|
</div>
|
||||||
<style>
|
<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 {
|
.lightbox-overlay {
|
||||||
display:none; position:fixed; inset:0; z-index:200;
|
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.is-open { display:flex; }
|
||||||
.lightbox-overlay img, .lightbox-overlay video {
|
.lightbox-stage {
|
||||||
max-width:92vw; max-height:88vh; border-radius:8px; object-fit:contain;
|
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 {
|
.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;
|
color:#fff; font-size:2.2rem; cursor:pointer; line-height:1; z-index:201;
|
||||||
|
width:44px; height:44px; border-radius:50%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<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) {
|
function openLightbox(url, isVideo) {
|
||||||
var lb = document.getElementById('lightbox');
|
var lb = document.getElementById('lightbox');
|
||||||
var img = document.getElementById('lightboxImg');
|
var img = document.getElementById('lightboxImg');
|
||||||
var vid = document.getElementById('lightboxVideo');
|
var vid = document.getElementById('lightboxVideo');
|
||||||
|
setLightboxState('is-loading');
|
||||||
|
lb.classList.add('is-open');
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
img.style.display = 'none';
|
img.style.display = 'none';
|
||||||
|
img.removeAttribute('src');
|
||||||
vid.style.display = '';
|
vid.style.display = '';
|
||||||
vid.src = url;
|
vid.src = url;
|
||||||
|
vid.load();
|
||||||
|
var playback = vid.play();
|
||||||
|
if (playback) playback.catch(function() {});
|
||||||
} else {
|
} else {
|
||||||
vid.style.display = 'none';
|
vid.style.display = 'none';
|
||||||
vid.pause && vid.pause(); vid.src = '';
|
vid.pause();
|
||||||
|
vid.removeAttribute('src');
|
||||||
|
vid.load();
|
||||||
img.style.display = '';
|
img.style.display = '';
|
||||||
img.src = url;
|
img.src = url;
|
||||||
}
|
}
|
||||||
lb.classList.add('is-open');
|
|
||||||
}
|
}
|
||||||
function closeLightbox(e) {
|
function closeLightbox(event) {
|
||||||
if (e && e.target !== document.getElementById('lightbox') && e.target.className !== 'lightbox-close') return;
|
|
||||||
var lb = document.getElementById('lightbox');
|
var lb = document.getElementById('lightbox');
|
||||||
|
if (event && event.target !== lb && !event.target.closest('.lightbox-close')) return;
|
||||||
lb.classList.remove('is-open');
|
lb.classList.remove('is-open');
|
||||||
var vid = document.getElementById('lightboxVideo');
|
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.getElementById('lightboxImg').addEventListener('load', function() { setLightboxState(null); });
|
||||||
document.addEventListener('click', function(e) {
|
document.getElementById('lightboxImg').addEventListener('error', function() { setLightboxState('is-error'); });
|
||||||
var a = e.target.closest('[data-lightbox]');
|
var lightboxVideo = document.getElementById('lightboxVideo');
|
||||||
if (a) { e.preventDefault(); openLightbox(a.href, a.dataset.lightbox === 'video'); }
|
['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>
|
</script>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ lang.code() }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>{{ t.nav_title }} — {{ t.privacy_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;
|
||||||
|
color: #1a1a2e; background: linear-gradient(170deg, #f0eeff 0%, #fff 60%);
|
||||||
|
min-height: 100vh; padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.doc {
|
||||||
|
max-width: 760px; margin: 0 auto; background: #fff; border-radius: 18px;
|
||||||
|
border: 1px solid #e8e6ff; box-shadow: 0 8px 40px rgba(108,99,255,0.08);
|
||||||
|
padding: 2.4rem 2rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 1.2rem; line-height: 1.25; }
|
||||||
|
h2 { font-size: 1.1rem; font-weight: 700; margin: 1.6rem 0 .5rem; }
|
||||||
|
p, li { color: #444; font-size: .98rem; line-height: 1.6; }
|
||||||
|
ul { padding-left: 1.3rem; margin: .3rem 0; }
|
||||||
|
li { margin: .25rem 0; }
|
||||||
|
.muted { color: #888; font-size: .85rem; margin-top: 1.8rem; }
|
||||||
|
.back-link {
|
||||||
|
display: inline-block; margin-top: 1.8rem; padding: .7rem 1.8rem; border-radius: 12px;
|
||||||
|
background: #6c63ff; color: #fff; font-weight: 700; text-decoration: none;
|
||||||
|
transition: background .2s, transform .2s;
|
||||||
|
}
|
||||||
|
.back-link:hover { background: #5a52d5; transform: translateY(-1px); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
<h1>{{ t.privacy_title }}</h1>
|
||||||
|
|
||||||
|
{% if lang.code() == "ru" %}
|
||||||
|
<p>Настоящая политика описывает, какие данные обрабатывает сайт «{{ t.nav_title }}» и как используются файлы cookie. Продолжая пользоваться сайтом, вы соглашаетесь с условиями, изложенными ниже.</p>
|
||||||
|
|
||||||
|
<h2>Какие данные мы собираем</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Данные из формы заявки</strong> — имя, телефон и комментарий, которые вы указываете добровольно, чтобы мы могли связаться с вами по поводу услуги.</li>
|
||||||
|
<li><strong>Файлы cookie и техническая информация</strong> — см. раздел ниже. IP-адреса мы не храним.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Какие файлы cookie используются</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Языковая настройка</strong> — запоминает выбранный язык интерфейса.</li>
|
||||||
|
<li><strong>Сессия</strong> — необходима для входа администратора в панель управления.</li>
|
||||||
|
<li><strong>Статистика посещений</strong> — анонимный идентификатор, который помогает считать число уникальных посещений и просмотров. Он не содержит персональных данных, не передаётся третьим лицам и не используется для рекламы.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Цели обработки</h2>
|
||||||
|
<p>Связь с вами по оставленной заявке, оказание услуг по уходу за животными, а также улучшение работы сайта на основе обезличенной статистики.</p>
|
||||||
|
|
||||||
|
<h2>Передача третьим лицам</h2>
|
||||||
|
<p>Мы не продаём и не передаём ваши данные третьим лицам, за исключением случаев, предусмотренных законодательством.</p>
|
||||||
|
|
||||||
|
<h2>Ваши права</h2>
|
||||||
|
<p>Вы можете запросить сведения об обработке ваших данных, их изменение или удаление, а также отозвать согласие, связавшись с нами по контактам, указанным на сайте. Файлы cookie можно отключить в настройках вашего браузера.</p>
|
||||||
|
|
||||||
|
<p class="muted">Дата последнего обновления: 2026. Оператор может уточнить реквизиты и контактные данные в этом документе.</p>
|
||||||
|
{% else %}
|
||||||
|
<p>This policy explains what data the “{{ t.nav_title }}” website processes and how cookies are used. By continuing to use the site, you agree to the terms set out below.</p>
|
||||||
|
|
||||||
|
<h2>What data we collect</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Request form data</strong> — the name, phone number and comment you voluntarily provide so we can contact you about the service.</li>
|
||||||
|
<li><strong>Cookies and technical information</strong> — see the section below. We do not store IP addresses.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Cookies we use</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Language preference</strong> — remembers your selected interface language.</li>
|
||||||
|
<li><strong>Session</strong> — required for the administrator to sign in to the control panel.</li>
|
||||||
|
<li><strong>Visit statistics</strong> — an anonymous identifier that helps count unique visits and views. It contains no personal data, is not shared with third parties, and is not used for advertising.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Purposes of processing</h2>
|
||||||
|
<p>Contacting you about your request, providing pet-sitting services, and improving the website based on anonymised statistics.</p>
|
||||||
|
|
||||||
|
<h2>Sharing with third parties</h2>
|
||||||
|
<p>We do not sell or share your data with third parties, except as required by law.</p>
|
||||||
|
|
||||||
|
<h2>Your rights</h2>
|
||||||
|
<p>You may request information about the processing of your data, its correction or deletion, and withdraw your consent by contacting us via the details provided on the site. Cookies can be disabled in your browser settings.</p>
|
||||||
|
|
||||||
|
<p class="muted">Last updated: 2026. The operator may add legal and contact details to this document.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="/?lang={{ lang.code() }}" class="back-link">{{ t.landing_thank_you_back }}</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {{ t.landing_thank_you_title }}</title>
|
<title>{{ t.nav_title }} — {{ t.landing_thank_you_title }}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<style>
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
@@ -35,5 +37,6 @@
|
|||||||
<p>{{ t.landing_thank_you_text }}</p>
|
<p>{{ t.landing_thank_you_text }}</p>
|
||||||
<a href="/?lang={{ lang.code() }}" class="back-link">{{ t.landing_thank_you_back }}</a>
|
<a href="/?lang={{ lang.code() }}" class="back-link">{{ t.landing_thank_you_back }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% include "partials/cookie_banner.html" %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user