Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46351351fc | ||
|
|
6449d8b59a | ||
|
|
9587622285 | ||
|
|
866118497f | ||
|
|
95154c56e7 | ||
|
|
8168d33440 | ||
|
|
5fd26c816a | ||
|
|
7f49b1f314 | ||
|
|
79c287eaa0 | ||
|
|
cec96cab1e | ||
|
|
a9188f919b | ||
|
|
0edb8c12aa | ||
|
|
15c9528f47 | ||
|
|
9ee5048a43 | ||
|
|
2d43600066 | ||
|
|
d6e6075469 | ||
|
|
289b1e8d37 | ||
|
|
c4823b7e64 |
No files matched your search
Generated
+1450
-126
File diff suppressed because it is too large.
Load diff
+7
-2
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "1.0.2"
|
version = "1.0.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
default-run = "web-petting"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json"] }
|
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json"] }
|
||||||
@@ -15,9 +16,13 @@ serde_json = "1"
|
|||||||
multer = "3"
|
multer = "3"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
tokio = { version = "1", features = ["fs"] }
|
tokio = { version = "1", features = ["fs", "process", "rt-multi-thread"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
idna = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
web-push-native = "0.5"
|
||||||
|
async-trait = "0.1"
|
||||||
|
aws-sdk-s3 = { version = "1", default-features = false, features = ["rustls", "rt-tokio"] }
|
||||||
+5
-3
@@ -1,16 +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
|
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
|
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())
|
||||||
+868
-54
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+290
@@ -131,6 +131,16 @@ 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,
|
||||||
@@ -151,9 +161,73 @@ pub struct Translations {
|
|||||||
pub settings_section_captcha: &'static str,
|
pub settings_section_captcha: &'static str,
|
||||||
pub settings_section_oidc: &'static str,
|
pub settings_section_oidc: &'static str,
|
||||||
pub settings_section_general: &'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,
|
||||||
@@ -211,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,
|
||||||
@@ -280,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,
|
||||||
@@ -292,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,
|
||||||
@@ -366,6 +464,16 @@ 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: "Контактная информация (отображается на лендинге)",
|
||||||
@@ -386,9 +494,72 @@ static RU: Translations = Translations {
|
|||||||
settings_section_captcha: "Защита от ботов",
|
settings_section_captcha: "Защита от ботов",
|
||||||
settings_section_oidc: "Единый вход (SSO / OIDC)",
|
settings_section_oidc: "Единый вход (SSO / OIDC)",
|
||||||
settings_section_general: "Сайт",
|
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: "На сегодня визитов нет.",
|
||||||
@@ -405,6 +576,11 @@ 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: "Предстоящие визиты",
|
||||||
@@ -416,6 +592,19 @@ 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: "Войти",
|
||||||
@@ -505,6 +694,11 @@ static RU: Translations = Translations {
|
|||||||
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: "Отзывов пока нет.",
|
||||||
@@ -591,6 +785,16 @@ 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)",
|
||||||
@@ -611,9 +815,72 @@ static EN: Translations = Translations {
|
|||||||
settings_section_captcha: "Bot protection",
|
settings_section_captcha: "Bot protection",
|
||||||
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
|
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
|
||||||
settings_section_general: "Site",
|
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.",
|
||||||
@@ -630,6 +897,11 @@ 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: "Visits",
|
portal_title: "Visits",
|
||||||
portal_upcoming: "Upcoming visits",
|
portal_upcoming: "Upcoming visits",
|
||||||
@@ -641,6 +913,19 @@ 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",
|
||||||
@@ -730,6 +1015,11 @@ static EN: Translations = Translations {
|
|||||||
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
|
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.",
|
||||||
|
|||||||
+41
-7
@@ -1,4 +1,5 @@
|
|||||||
mod admin;
|
mod admin;
|
||||||
|
mod analytics;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod migrations;
|
mod migrations;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
@@ -7,8 +8,7 @@ mod telegram;
|
|||||||
mod turnstile;
|
mod turnstile;
|
||||||
mod tz;
|
mod tz;
|
||||||
mod uploads;
|
mod uploads;
|
||||||
|
mod web_push;
|
||||||
use tracing_subscriber;
|
|
||||||
|
|
||||||
use cot::cli::CliMetadata;
|
use cot::cli::CliMetadata;
|
||||||
use cot::config::{
|
use cot::config::{
|
||||||
@@ -17,7 +17,7 @@ use cot::config::{
|
|||||||
};
|
};
|
||||||
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};
|
||||||
@@ -40,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()
|
||||||
}
|
}
|
||||||
@@ -73,7 +79,13 @@ fn debug_enabled(config_name: &str) -> bool {
|
|||||||
fn database_url() -> String {
|
fn database_url() -> String {
|
||||||
std::env::var("WEB_PETTING_DATABASE_URL")
|
std::env::var("WEB_PETTING_DATABASE_URL")
|
||||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||||
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5432/web_petting".to_string())
|
.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 {
|
||||||
@@ -120,10 +132,32 @@ impl Project for PettingProject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cot::main]
|
fn main() {
|
||||||
fn main() -> impl Project {
|
|
||||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||||
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
||||||
PettingProject
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+9
-1
@@ -3,5 +3,13 @@
|
|||||||
//! Squashed for the PostgreSQL migration on 2026-07-11.
|
//! Squashed for the PostgreSQL migration on 2026-07-11.
|
||||||
|
|
||||||
pub mod m_0001_initial;
|
pub mod m_0001_initial;
|
||||||
|
pub mod m_0002_push_subscription;
|
||||||
|
pub mod m_0003_analytics_event;
|
||||||
|
pub mod m_0004_analytics_event_meta;
|
||||||
/// The list of migrations for current app.
|
/// The list of migrations for current app.
|
||||||
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[&m_0001_initial::Migration];
|
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
||||||
|
&m_0001_initial::Migration,
|
||||||
|
&m_0002_push_subscription::Migration,
|
||||||
|
&m_0003_analytics_event::Migration,
|
||||||
|
&m_0004_analytics_event_meta::Migration,
|
||||||
|
];
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//! Append-only analytics events (landing visits, portal opens, media views).
|
||||||
|
|
||||||
|
#[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_analytics_event";
|
||||||
|
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__analytics_event"))
|
||||||
|
.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("event_type"),
|
||||||
|
<String as ::cot::db::DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||||
|
::cot::db::migrations::Field::new(
|
||||||
|
::cot::db::Identifier::new("client_id"),
|
||||||
|
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||||
|
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||||
|
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||||
|
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||||
|
)
|
||||||
|
.set_null(
|
||||||
|
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::NULLABLE,
|
||||||
|
),
|
||||||
|
::cot::db::migrations::Field::new(
|
||||||
|
::cot::db::Identifier::new("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,
|
||||||
|
)
|
||||||
|
.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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -206,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]
|
||||||
@@ -217,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,
|
||||||
|
}
|
||||||
+727
-44
@@ -1,17 +1,22 @@
|
|||||||
|
use chrono::Datelike;
|
||||||
use cot::Template;
|
use cot::Template;
|
||||||
use cot::db::{Auto, Database, Model};
|
use cot::db::{Auto, Database, ForeignKey, Model};
|
||||||
use cot::html::Html;
|
use cot::html::Html;
|
||||||
use cot::request::Request;
|
use cot::request::Request;
|
||||||
use cot::request::extractors::Path;
|
use cot::request::extractors::Path;
|
||||||
use cot::response::{IntoResponse, Redirect, Response};
|
use cot::response::{IntoResponse, Redirect, Response};
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::ops::Deref;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use cot::db::query;
|
use cot::db::query;
|
||||||
|
|
||||||
use crate::i18n::{Lang, Translations};
|
use crate::i18n::{Lang, Translations};
|
||||||
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
|
use crate::models::{
|
||||||
|
Client, EventType, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit,
|
||||||
|
};
|
||||||
use crate::telegram;
|
use crate::telegram;
|
||||||
|
|
||||||
fn detect_lang(request: &Request) -> Lang {
|
fn detect_lang(request: &Request) -> Lang {
|
||||||
@@ -73,12 +78,26 @@ struct LandingTemplate<'a> {
|
|||||||
contact_info: String,
|
contact_info: String,
|
||||||
pricing_info: String,
|
pricing_info: String,
|
||||||
seo_keywords: String,
|
seo_keywords: String,
|
||||||
testimonials: Vec<Testimonial>,
|
testimonials: Vec<TestimonialView>,
|
||||||
site_domain: String,
|
site_domain: String,
|
||||||
review_count: usize,
|
review_count: usize,
|
||||||
turnstile_site_key: String,
|
turnstile_site_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TestimonialView {
|
||||||
|
testimonial: Testimonial,
|
||||||
|
image_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for TestimonialView {
|
||||||
|
type Target = Testimonial;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.testimonial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
#[template(path = "thank_you.html")]
|
#[template(path = "thank_you.html")]
|
||||||
struct ThankYouTemplate<'a> {
|
struct ThankYouTemplate<'a> {
|
||||||
@@ -86,6 +105,19 @@ struct ThankYouTemplate<'a> {
|
|||||||
lang: Lang,
|
lang: Lang,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "privacy.html")]
|
||||||
|
struct PrivacyTemplate<'a> {
|
||||||
|
t: &'a Translations,
|
||||||
|
lang: Lang,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn privacy_page(request: Request) -> cot::Result<Response> {
|
||||||
|
let lang = detect_lang(&request);
|
||||||
|
let body = PrivacyTemplate { t: lang.t(), lang }.render()?;
|
||||||
|
html_response(body, lang)
|
||||||
|
}
|
||||||
|
|
||||||
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||||
let lang = detect_lang(&request);
|
let lang = detect_lang(&request);
|
||||||
|
|
||||||
@@ -115,6 +147,22 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
|||||||
ua = ua,
|
ua = ua,
|
||||||
"landing visit"
|
"landing visit"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Analytics: count unique landing visits (skip bots/crawlers).
|
||||||
|
let (visitor_id, set_visitor_cookie) = crate::analytics::ensure_visitor_id(&request);
|
||||||
|
if !crate::analytics::is_bot(ua) {
|
||||||
|
let meta = crate::analytics::RequestMeta::from_request(&request);
|
||||||
|
crate::analytics::record(
|
||||||
|
&db,
|
||||||
|
EventType::LandingView,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
visitor_id.clone(),
|
||||||
|
meta,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
let key = "contact_info".to_string();
|
let key = "contact_info".to_string();
|
||||||
let contact_info = query!(Setting, $key == key)
|
let contact_info = query!(Setting, $key == key)
|
||||||
.get(&db)
|
.get(&db)
|
||||||
@@ -144,19 +192,48 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
|||||||
testimonials.retain(|t| t.status == "active");
|
testimonials.retain(|t| t.status == "active");
|
||||||
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
||||||
let review_count = testimonials.len();
|
let review_count = testimonials.len();
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
|
let mut testimonial_views = Vec::with_capacity(testimonials.len());
|
||||||
|
for testimonial in testimonials {
|
||||||
|
let image_url = match testimonial.image_path.as_deref() {
|
||||||
|
Some(path) => Some(
|
||||||
|
storage
|
||||||
|
.public_url(
|
||||||
|
path,
|
||||||
|
format!("/testimonial-image/{}", testimonial.id.unwrap()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(crate::uploads::storage_error)?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
testimonial_views.push(TestimonialView {
|
||||||
|
testimonial,
|
||||||
|
image_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
let body = LandingTemplate {
|
let body = LandingTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
contact_info,
|
contact_info,
|
||||||
pricing_info,
|
pricing_info,
|
||||||
seo_keywords,
|
seo_keywords,
|
||||||
testimonials,
|
testimonials: testimonial_views,
|
||||||
site_domain,
|
site_domain,
|
||||||
review_count,
|
review_count,
|
||||||
turnstile_site_key,
|
turnstile_site_key,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
let mut resp = html_response(body, lang)?;
|
||||||
|
if set_visitor_cookie {
|
||||||
|
resp.headers_mut().append(
|
||||||
|
"set-cookie",
|
||||||
|
crate::analytics::visitor_cookie(&visitor_id)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -212,7 +289,69 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
|
|||||||
struct PortalVisit {
|
struct PortalVisit {
|
||||||
visit: Visit,
|
visit: Visit,
|
||||||
admin_name: String,
|
admin_name: String,
|
||||||
media: Vec<Media>,
|
media: Vec<PortalMediaView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct PortalMediaView {
|
||||||
|
media: Media,
|
||||||
|
url: String,
|
||||||
|
thumbnail_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for PortalMediaView {
|
||||||
|
type Target = Media;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.media
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn portal_media_view(
|
||||||
|
storage: &crate::uploads::Storage,
|
||||||
|
media: Media,
|
||||||
|
client_token: &str,
|
||||||
|
) -> cot::Result<PortalMediaView> {
|
||||||
|
let media_id = media.id.unwrap();
|
||||||
|
let delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
|
||||||
|
// Always route full-size opens through our handler so the view is counted
|
||||||
|
// even with R2 storage (the handler records the event, then redirects to the
|
||||||
|
// signed URL). Thumbnails are still served directly and are not counted.
|
||||||
|
let url = format!("/client/{client_token}/media/{media_id}");
|
||||||
|
let thumbnail_fallback = format!("/client/{client_token}/media/{media_id}/thumbnail");
|
||||||
|
let thumbnail_url = if storage.is_r2()
|
||||||
|
&& storage
|
||||||
|
.exists(&delivery.thumbnail_path)
|
||||||
|
.await
|
||||||
|
.map_err(crate::uploads::storage_error)?
|
||||||
|
{
|
||||||
|
storage
|
||||||
|
.public_url(&delivery.thumbnail_path, thumbnail_fallback)
|
||||||
|
.await
|
||||||
|
.map_err(crate::uploads::storage_error)?
|
||||||
|
} else {
|
||||||
|
thumbnail_fallback
|
||||||
|
};
|
||||||
|
Ok(PortalMediaView {
|
||||||
|
media,
|
||||||
|
url,
|
||||||
|
thumbnail_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CalendarDay {
|
||||||
|
number: u32,
|
||||||
|
class_name: &'static str,
|
||||||
|
href: Option<String>,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CalendarMonth {
|
||||||
|
label: String,
|
||||||
|
leading_blanks: Vec<u8>,
|
||||||
|
days: Vec<CalendarDay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -221,10 +360,69 @@ struct ClientPortalTemplate<'a> {
|
|||||||
t: &'a Translations,
|
t: &'a Translations,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
client: Client,
|
client: Client,
|
||||||
|
header_media: Vec<PortalMediaView>,
|
||||||
upcoming: Vec<PortalVisit>,
|
upcoming: Vec<PortalVisit>,
|
||||||
past: Vec<PortalVisit>,
|
past: Vec<PortalVisit>,
|
||||||
feedback_sent: bool,
|
feedback_sent: bool,
|
||||||
turnstile_site_key: String,
|
turnstile_site_key: String,
|
||||||
|
notifications_enabled: bool,
|
||||||
|
show_portal_settings: bool,
|
||||||
|
vapid_public_key: String,
|
||||||
|
calendar_months: Vec<CalendarMonth>,
|
||||||
|
page: usize,
|
||||||
|
total_pages: usize,
|
||||||
|
has_previous_page: bool,
|
||||||
|
has_next_page: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORTAL_VISITS_PER_PAGE: usize = 10;
|
||||||
|
const PORTAL_HEADER_MEDIA_LIMIT: usize = 20;
|
||||||
|
|
||||||
|
fn query_page(request: &Request) -> usize {
|
||||||
|
request
|
||||||
|
.uri()
|
||||||
|
.query()
|
||||||
|
.and_then(|query| {
|
||||||
|
query.split('&').find_map(|part| {
|
||||||
|
part.strip_prefix("page=")
|
||||||
|
.and_then(|value| value.parse::<usize>().ok())
|
||||||
|
.filter(|page| *page > 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn month_label(year: i32, month: u32, lang: Lang) -> String {
|
||||||
|
const RU: [&str; 12] = [
|
||||||
|
"Январь",
|
||||||
|
"Февраль",
|
||||||
|
"Март",
|
||||||
|
"Апрель",
|
||||||
|
"Май",
|
||||||
|
"Июнь",
|
||||||
|
"Июль",
|
||||||
|
"Август",
|
||||||
|
"Сентябрь",
|
||||||
|
"Октябрь",
|
||||||
|
"Ноябрь",
|
||||||
|
"Декабрь",
|
||||||
|
];
|
||||||
|
const EN: [&str; 12] = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
];
|
||||||
|
let names = if lang == Lang::Ru { RU } else { EN };
|
||||||
|
format!("{} {year}", names[(month - 1) as usize])
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn client_portal(
|
async fn client_portal(
|
||||||
@@ -238,6 +436,7 @@ async fn client_portal(
|
|||||||
.query()
|
.query()
|
||||||
.map(|q| q.split('&').any(|p| p == "feedback=ok"))
|
.map(|q| q.split('&').any(|p| p == "feedback=ok"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
let requested_page = query_page(&request);
|
||||||
|
|
||||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||||
Some(c) if c.status != "deleted" => c,
|
Some(c) if c.status != "deleted" => c,
|
||||||
@@ -246,6 +445,19 @@ async fn client_portal(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let client_id = client.id.unwrap();
|
let client_id = client.id.unwrap();
|
||||||
|
|
||||||
|
// Analytics: count how clients open their private portal page.
|
||||||
|
let (visitor_id, set_visitor_cookie) = crate::analytics::ensure_visitor_id(&request);
|
||||||
|
crate::analytics::record(
|
||||||
|
&db,
|
||||||
|
EventType::PortalOpen,
|
||||||
|
Some(client_id),
|
||||||
|
None,
|
||||||
|
visitor_id.clone(),
|
||||||
|
crate::analytics::RequestMeta::from_request(&request),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let tz = crate::tz::load_tz(&db).await;
|
let tz = crate::tz::load_tz(&db).await;
|
||||||
let today = crate::tz::today_in_tz(tz);
|
let today = crate::tz::today_in_tz(tz);
|
||||||
|
|
||||||
@@ -261,8 +473,77 @@ async fn client_portal(
|
|||||||
.then(a.time_start.cmp(&b.time_start))
|
.then(a.time_start.cmp(&b.time_start))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mut upcoming_visits = Vec::new();
|
||||||
|
let mut past_visits = Vec::new();
|
||||||
|
for v in visits {
|
||||||
|
if v.visit_date >= today && v.status == "scheduled" {
|
||||||
|
upcoming_visits.push(v);
|
||||||
|
} else {
|
||||||
|
past_visits.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
past_visits.reverse(); // newest first
|
||||||
|
|
||||||
|
let total_pages = past_visits.len().div_ceil(PORTAL_VISITS_PER_PAGE).max(1);
|
||||||
|
let page = requested_page.min(total_pages);
|
||||||
|
let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE;
|
||||||
|
let page_end = (page_start + PORTAL_VISITS_PER_PAGE).min(past_visits.len());
|
||||||
|
let visible_visit_ids: HashSet<i64> = past_visits[page_start..page_end]
|
||||||
|
.iter()
|
||||||
|
.chain(upcoming_visits.iter())
|
||||||
|
.map(|visit| visit.id.unwrap())
|
||||||
|
.collect();
|
||||||
|
|
||||||
let users = User::objects().all(&db).await?;
|
let users = User::objects().all(&db).await?;
|
||||||
let all_media = Media::objects().all(&db).await?;
|
let all_media = Media::objects().all(&db).await?;
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
|
let portal_visit_ids: HashSet<i64> = past_visits
|
||||||
|
.iter()
|
||||||
|
.chain(upcoming_visits.iter())
|
||||||
|
.map(|visit| visit.id.unwrap())
|
||||||
|
.collect();
|
||||||
|
let mut client_media: Vec<_> = all_media
|
||||||
|
.into_iter()
|
||||||
|
.filter(|media| {
|
||||||
|
media.status == "active"
|
||||||
|
&& media.client_id.primary_key().unwrap() == client_id
|
||||||
|
&& media
|
||||||
|
.visit_id
|
||||||
|
.as_ref()
|
||||||
|
.map(|foreign_key| {
|
||||||
|
portal_visit_ids.contains(&foreign_key.primary_key().unwrap())
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
client_media.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.created_at
|
||||||
|
.cmp(&left.created_at)
|
||||||
|
.then_with(|| right.id.unwrap().cmp(&left.id.unwrap()))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut media_by_visit: HashMap<i64, Vec<PortalMediaView>> = HashMap::new();
|
||||||
|
let mut header_media = Vec::with_capacity(client_media.len().min(PORTAL_HEADER_MEDIA_LIMIT));
|
||||||
|
for media in client_media {
|
||||||
|
let visit_id = media
|
||||||
|
.visit_id
|
||||||
|
.as_ref()
|
||||||
|
.map(|foreign_key| foreign_key.primary_key().unwrap())
|
||||||
|
.expect("client portal media was filtered to visit media");
|
||||||
|
let include_in_header = header_media.len() < PORTAL_HEADER_MEDIA_LIMIT;
|
||||||
|
let include_in_visit = visible_visit_ids.contains(&visit_id);
|
||||||
|
if !include_in_header && !include_in_visit {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let media_view = portal_media_view(&storage, media, &client.media_token).await?;
|
||||||
|
if include_in_header {
|
||||||
|
header_media.push(media_view.clone());
|
||||||
|
}
|
||||||
|
if include_in_visit {
|
||||||
|
media_by_visit.entry(visit_id).or_default().push(media_view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let build_portal_visit = |v: Visit| -> PortalVisit {
|
let build_portal_visit = |v: Visit| -> PortalVisit {
|
||||||
let uid: i64 = v.user_id.primary_key().unwrap();
|
let uid: i64 = v.user_id.primary_key().unwrap();
|
||||||
@@ -272,18 +553,7 @@ async fn client_portal(
|
|||||||
.map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string())
|
.map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let vid = v.id.unwrap();
|
let vid = v.id.unwrap();
|
||||||
let media: Vec<Media> = all_media
|
let media = media_by_visit.get(&vid).cloned().unwrap_or_default();
|
||||||
.iter()
|
|
||||||
.filter(|m| {
|
|
||||||
m.status == "active"
|
|
||||||
&& m.client_id.primary_key().unwrap() == client_id
|
|
||||||
&& m.visit_id
|
|
||||||
.as_ref()
|
|
||||||
.map(|fk| fk.primary_key().unwrap() == vid)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
PortalVisit {
|
PortalVisit {
|
||||||
visit: v,
|
visit: v,
|
||||||
admin_name,
|
admin_name,
|
||||||
@@ -291,29 +561,312 @@ async fn client_portal(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut upcoming = Vec::new();
|
let past = past_visits[page_start..page_end]
|
||||||
let mut past = Vec::new();
|
.iter()
|
||||||
for v in visits {
|
.cloned()
|
||||||
if v.visit_date >= today && v.status == "scheduled" {
|
.map(&build_portal_visit)
|
||||||
upcoming.push(build_portal_visit(v));
|
.collect();
|
||||||
|
let upcoming: Vec<_> = upcoming_visits
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(&build_portal_visit)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut month_keys: Vec<(i32, u32)> = past_visits
|
||||||
|
.iter()
|
||||||
|
.chain(upcoming_visits.iter())
|
||||||
|
.map(|visit| (visit.visit_date.year(), visit.visit_date.month()))
|
||||||
|
.collect();
|
||||||
|
month_keys.sort();
|
||||||
|
month_keys.dedup();
|
||||||
|
month_keys.reverse();
|
||||||
|
let calendar_months: Vec<CalendarMonth> = month_keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|(year, month)| {
|
||||||
|
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
||||||
|
let next_month = if month == 12 {
|
||||||
|
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
|
||||||
} else {
|
} else {
|
||||||
past.push(build_portal_visit(v));
|
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
|
||||||
|
};
|
||||||
|
let days_in_month = (next_month - first).num_days() as u32;
|
||||||
|
let leading_blanks = vec![0; first.weekday().num_days_from_monday() as usize];
|
||||||
|
let days = (1..=days_in_month)
|
||||||
|
.map(|day| {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap();
|
||||||
|
if let Some(_visit) = upcoming_visits
|
||||||
|
.iter()
|
||||||
|
.find(|visit| visit.visit_date == date)
|
||||||
|
{
|
||||||
|
CalendarDay {
|
||||||
|
number: day,
|
||||||
|
class_name: "future",
|
||||||
|
href: None,
|
||||||
|
title: lang.t().portal_future_visit.to_string(),
|
||||||
|
}
|
||||||
|
} else if let Some((index, visit)) = past_visits
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, visit)| visit.visit_date == date)
|
||||||
|
{
|
||||||
|
CalendarDay {
|
||||||
|
number: day,
|
||||||
|
class_name: "past",
|
||||||
|
href: Some(format!(
|
||||||
|
"?page={}#visit-{}",
|
||||||
|
index / PORTAL_VISITS_PER_PAGE + 1,
|
||||||
|
visit.id.unwrap()
|
||||||
|
)),
|
||||||
|
title: lang.t().visit_status(&visit.status).to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CalendarDay {
|
||||||
|
number: day,
|
||||||
|
class_name: "empty",
|
||||||
|
href: None,
|
||||||
|
title: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
past.reverse(); // newest first
|
})
|
||||||
|
.collect();
|
||||||
|
CalendarMonth {
|
||||||
|
label: month_label(year, month, lang),
|
||||||
|
leading_blanks,
|
||||||
|
days,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let notification_key = "client_notifications_enabled".to_string();
|
||||||
|
let vapid_public_key = crate::web_push::load_config(&db)
|
||||||
|
.await
|
||||||
|
.map(|config| config.public_key)
|
||||||
|
.unwrap_or_default();
|
||||||
|
// The administrator setting controls whether the client can see notification
|
||||||
|
// controls. Keep this independent from VAPID validation so a configuration
|
||||||
|
// error is visible in the modal instead of silently removing the button.
|
||||||
|
let notifications_enabled = query!(Setting, $key == notification_key)
|
||||||
|
.get(&db)
|
||||||
|
.await?
|
||||||
|
.map(|setting| setting.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||||
|
let show_portal_settings = notifications_enabled || !calendar_months.is_empty();
|
||||||
let body = ClientPortalTemplate {
|
let body = ClientPortalTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
client,
|
client,
|
||||||
|
header_media,
|
||||||
upcoming,
|
upcoming,
|
||||||
past,
|
past,
|
||||||
feedback_sent,
|
feedback_sent,
|
||||||
turnstile_site_key,
|
turnstile_site_key,
|
||||||
|
notifications_enabled,
|
||||||
|
show_portal_settings,
|
||||||
|
vapid_public_key,
|
||||||
|
calendar_months,
|
||||||
|
page,
|
||||||
|
total_pages,
|
||||||
|
has_previous_page: page > 1,
|
||||||
|
has_next_page: page < total_pages,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
let mut resp = html_response(body, lang)?;
|
||||||
|
if set_visitor_cookie {
|
||||||
|
resp.headers_mut().append(
|
||||||
|
"set-cookie",
|
||||||
|
crate::analytics::visitor_cookie(&visitor_id)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PushKeysForm {
|
||||||
|
p256dh: String,
|
||||||
|
auth: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PushSubscriptionForm {
|
||||||
|
endpoint: String,
|
||||||
|
keys: PushKeysForm,
|
||||||
|
#[serde(default)]
|
||||||
|
language: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn portal_push_subscribe(
|
||||||
|
request: Request,
|
||||||
|
db: Database,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> cot::Result<Response> {
|
||||||
|
tracing::info!("client Web Push subscription request");
|
||||||
|
if crate::web_push::load_config(&db).await.is_none() {
|
||||||
|
return Html::new("404").into_response();
|
||||||
|
}
|
||||||
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||||
|
Some(client) if client.status != "deleted" => client,
|
||||||
|
_ => return Html::new("404").into_response(),
|
||||||
|
};
|
||||||
|
let bytes = request.into_body().into_bytes().await?;
|
||||||
|
let form: PushSubscriptionForm =
|
||||||
|
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
|
if !form.endpoint.starts_with("https://")
|
||||||
|
|| form.endpoint.len() > 4096
|
||||||
|
|| form.keys.p256dh.len() > 512
|
||||||
|
|| form.keys.auth.len() > 256
|
||||||
|
{
|
||||||
|
let mut response = Response::new(cot::Body::fixed(
|
||||||
|
"{\"ok\":false,\"error\":\"invalid subscription\"}",
|
||||||
|
));
|
||||||
|
*response.status_mut() = cot::StatusCode::BAD_REQUEST;
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-type", "application/json".parse().unwrap());
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
client_id = client.id.unwrap(),
|
||||||
|
"client Web Push subscription saved"
|
||||||
|
);
|
||||||
|
let endpoint = form.endpoint.clone();
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
||||||
|
.get(&db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
subscription.client_id = ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap()));
|
||||||
|
subscription.p256dh = form.keys.p256dh;
|
||||||
|
subscription.auth = form.keys.auth;
|
||||||
|
subscription.language = if form.language == "ru" { "ru" } else { "en" }.to_string();
|
||||||
|
subscription.status = "active".to_string();
|
||||||
|
subscription.updated_at = now;
|
||||||
|
subscription.save(&db).await?;
|
||||||
|
} else {
|
||||||
|
let mut subscription = PushSubscription {
|
||||||
|
id: Auto::auto(),
|
||||||
|
client_id: ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap())),
|
||||||
|
endpoint: form.endpoint,
|
||||||
|
p256dh: form.keys.p256dh,
|
||||||
|
auth: form.keys.auth,
|
||||||
|
language: if form.language == "ru" { "ru" } else { "en" }.to_string(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
subscription.save(&db).await?;
|
||||||
|
}
|
||||||
|
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-type", "application/json".parse().unwrap());
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn portal_push_unsubscribe(
|
||||||
|
request: Request,
|
||||||
|
db: Database,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> cot::Result<Response> {
|
||||||
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||||
|
Some(client) if client.status != "deleted" => client,
|
||||||
|
_ => return Html::new("404").into_response(),
|
||||||
|
};
|
||||||
|
let bytes = request.into_body().into_bytes().await?;
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?;
|
||||||
|
let endpoint = value
|
||||||
|
.get("endpoint")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint)
|
||||||
|
.get(&db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
if subscription.client_id.primary_key().unwrap() == client.id.unwrap() {
|
||||||
|
subscription.status = "archived".to_string();
|
||||||
|
subscription.updated_at = chrono::Utc::now().naive_utc();
|
||||||
|
subscription.save(&db).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut response = Response::new(cot::Body::fixed("{\"ok\":true}"));
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-type", "application/json".parse().unwrap());
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn web_manifest(_request: Request, Path(token): Path<String>) -> cot::Result<Response> {
|
||||||
|
let manifest = serde_json::json!({
|
||||||
|
"id": format!("/client/{token}"),
|
||||||
|
"name": "Pet Sitting Visits",
|
||||||
|
"short_name": "Pet Visits",
|
||||||
|
"start_url": format!("/client/{token}"),
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f8f7ff",
|
||||||
|
"theme_color": "#7c6cff",
|
||||||
|
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
|
||||||
|
});
|
||||||
|
let mut response = Response::new(cot::Body::fixed(manifest.to_string()));
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-type", "application/manifest+json".parse().unwrap());
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn service_worker(_request: Request) -> cot::Result<Response> {
|
||||||
|
let script = r#"
|
||||||
|
self.addEventListener('install', function(event) {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
self.addEventListener('activate', function(event) {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
self.addEventListener('push', function(event) {
|
||||||
|
var data = event.data ? event.data.json() : {};
|
||||||
|
event.waitUntil(self.registration.showNotification(data.title || 'Pet Visits', {
|
||||||
|
body: data.body || '', tag: data.tag || 'visit', data: { url: data.url || '/' },
|
||||||
|
icon: '/favicon.svg', badge: '/favicon.svg'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
self.addEventListener('notificationclick', function(event) {
|
||||||
|
event.notification.close();
|
||||||
|
var target = new URL(event.notification.data.url || '/', self.location.origin).href;
|
||||||
|
event.waitUntil((async function() {
|
||||||
|
var list = await clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
if (list[i].url === target && 'focus' in list[i]) {
|
||||||
|
return list[i].focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var j = 0; j < list.length; j++) {
|
||||||
|
if ('navigate' in list[j] && 'focus' in list[j]) {
|
||||||
|
try {
|
||||||
|
var navigated = await list[j].navigate(target);
|
||||||
|
return navigated ? navigated.focus() : list[j].focus();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clients.openWindow) return clients.openWindow(target);
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
"#;
|
||||||
|
let mut response = Response::new(cot::Body::fixed(script));
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-type", "application/javascript".parse().unwrap());
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("service-worker-allowed", "/".parse().unwrap());
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"cache-control",
|
||||||
|
"no-cache, no-store, must-revalidate".parse().unwrap(),
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -400,28 +953,56 @@ async fn portal_media(
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
|
|
||||||
match {
|
// Analytics: count full-size media opens (photos and videos alike). A video
|
||||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
// streams via many HTTP range requests, so we dedupe by visitor+media within
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
// a short window to count a single open instead of every chunk/seek.
|
||||||
"png" => "image/png",
|
let visitor_id = crate::analytics::visitor_id_from_request(&request)
|
||||||
"heic" | "heif" => "image/heic",
|
.unwrap_or_else(crate::analytics::new_visitor_id);
|
||||||
"webp" => "image/webp",
|
let recently_viewed = crate::analytics::media_viewed_recently(
|
||||||
"mp4" => "video/mp4",
|
&db,
|
||||||
"mov" => "video/quicktime",
|
media_id,
|
||||||
"avi" => "video/x-msvideo",
|
&visitor_id,
|
||||||
"mkv" => "video/x-matroska",
|
chrono::Duration::minutes(30),
|
||||||
"webm" => "video/webm",
|
)
|
||||||
_ => "application/octet-stream",
|
.await
|
||||||
};
|
.unwrap_or(false);
|
||||||
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
|
if !recently_viewed {
|
||||||
} {
|
crate::analytics::record(
|
||||||
|
&db,
|
||||||
|
EventType::MediaView,
|
||||||
|
Some(client_id),
|
||||||
|
Some(media_id),
|
||||||
|
visitor_id,
|
||||||
|
crate::analytics::RequestMeta::from_request(&request),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
|
let display_path =
|
||||||
|
crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path;
|
||||||
|
if storage.is_r2() {
|
||||||
|
let url = storage
|
||||||
|
.public_url(&display_path, String::new())
|
||||||
|
.await
|
||||||
|
.map_err(crate::uploads::storage_error)?;
|
||||||
|
return Redirect::new(url).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::uploads::ranged_local_file_response(
|
||||||
|
&display_path,
|
||||||
|
crate::uploads::content_type_for_path(&display_path),
|
||||||
|
range.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(response) => Ok(response),
|
Ok(response) => Ok(response),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "uploads",
|
target: "uploads",
|
||||||
media_id,
|
media_id,
|
||||||
db_path = %media.file_path,
|
db_path = %display_path,
|
||||||
resolved_path = %crate::uploads::resolved_display_path(&media.file_path),
|
resolved_path = %crate::uploads::resolved_display_path(&display_path),
|
||||||
error = %err,
|
error = %err,
|
||||||
"portal media file is missing or unreadable"
|
"portal media file is missing or unreadable"
|
||||||
);
|
);
|
||||||
@@ -430,6 +1011,76 @@ async fn portal_media(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn portal_media_thumbnail(
|
||||||
|
_request: Request,
|
||||||
|
db: Database,
|
||||||
|
Path((token, media_id)): Path<(String, i64)>,
|
||||||
|
) -> cot::Result<Response> {
|
||||||
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||||
|
Some(client) if client.status != "deleted" => client,
|
||||||
|
_ => return Html::new("404").into_response(),
|
||||||
|
};
|
||||||
|
let media = match query!(Media, $id == media_id).get(&db).await? {
|
||||||
|
Some(media)
|
||||||
|
if media.client_id.primary_key().unwrap() == client.id.unwrap()
|
||||||
|
&& media.status == "active" =>
|
||||||
|
{
|
||||||
|
media
|
||||||
|
}
|
||||||
|
_ => return Html::new("404").into_response(),
|
||||||
|
};
|
||||||
|
if let Some(visit) = &media.visit_id {
|
||||||
|
let visit_id = visit.primary_key().unwrap();
|
||||||
|
match query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
|
Some(visit) if visit.status != "deleted" => {}
|
||||||
|
_ => return Html::new("404").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
|
let display_path = match crate::uploads::ensure_media_delivery_paths(
|
||||||
|
&storage,
|
||||||
|
&media.file_type,
|
||||||
|
&media.file_path,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(paths) => paths.thumbnail_path,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
|
||||||
|
if media.file_type == "photo" {
|
||||||
|
media.file_path.clone()
|
||||||
|
} else {
|
||||||
|
return Html::new("404").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if storage.is_r2() {
|
||||||
|
let url = storage
|
||||||
|
.public_url(&display_path, String::new())
|
||||||
|
.await
|
||||||
|
.map_err(crate::uploads::storage_error)?;
|
||||||
|
return Redirect::new(url).into_response();
|
||||||
|
}
|
||||||
|
let content_type = if display_path == media.file_path {
|
||||||
|
crate::uploads::content_type_for_path(&media.file_path)
|
||||||
|
} else {
|
||||||
|
"image/jpeg"
|
||||||
|
};
|
||||||
|
match crate::uploads::ranged_local_file_response(&display_path, content_type, None).await {
|
||||||
|
Ok(mut response) => {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"cache-control",
|
||||||
|
"private, max-age=31536000, immutable".parse().unwrap(),
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(media_id, %error, "failed to read portal media thumbnail");
|
||||||
|
Html::new("404").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn serve_testimonial_image(
|
async fn serve_testimonial_image(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -443,7 +1094,15 @@ async fn serve_testimonial_image(
|
|||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => return Html::new("404").into_response(),
|
None => return Html::new("404").into_response(),
|
||||||
};
|
};
|
||||||
match crate::uploads::read_db_file(&path).await {
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
|
if storage.is_r2() {
|
||||||
|
let url = storage
|
||||||
|
.public_url(&path, String::new())
|
||||||
|
.await
|
||||||
|
.map_err(crate::uploads::storage_error)?;
|
||||||
|
return Redirect::new(url).into_response();
|
||||||
|
}
|
||||||
|
match storage.read(&path).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
@@ -506,6 +1165,8 @@ async fn serve_static(_request: Request, Path(filename): Path<String>) -> cot::R
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let content_type = match ext {
|
let content_type = match ext {
|
||||||
|
"css" => "text/css; charset=utf-8",
|
||||||
|
"js" => "text/javascript; charset=utf-8",
|
||||||
"png" => "image/png",
|
"png" => "image/png",
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
"webp" => "image/webp",
|
"webp" => "image/webp",
|
||||||
@@ -578,9 +1239,16 @@ pub fn public_router() -> Router {
|
|||||||
Router::with_urls([
|
Router::with_urls([
|
||||||
Route::with_handler_and_name("/", landing_page, "landing"),
|
Route::with_handler_and_name("/", landing_page, "landing"),
|
||||||
Route::with_handler_and_name("/favicon.svg", favicon, "favicon"),
|
Route::with_handler_and_name("/favicon.svg", favicon, "favicon"),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/client/{token}/manifest.webmanifest",
|
||||||
|
web_manifest,
|
||||||
|
"web-manifest",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name("/service-worker.js", service_worker, "service-worker"),
|
||||||
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
|
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
|
||||||
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
|
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
|
||||||
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
|
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
|
||||||
|
Route::with_handler_and_name("/privacy", privacy_page, "privacy"),
|
||||||
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
|
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/testimonial-image/{id}",
|
"/testimonial-image/{id}",
|
||||||
@@ -588,6 +1256,16 @@ pub fn public_router() -> Router {
|
|||||||
"testimonial-image",
|
"testimonial-image",
|
||||||
),
|
),
|
||||||
Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"),
|
Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/client/{token}/push/subscribe",
|
||||||
|
portal_push_subscribe,
|
||||||
|
"client-push-subscribe",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/client/{token}/push/unsubscribe",
|
||||||
|
portal_push_unsubscribe,
|
||||||
|
"client-push-unsubscribe",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/client/{token}/{visit_id}/feedback",
|
"/client/{token}/{visit_id}/feedback",
|
||||||
submit_feedback,
|
submit_feedback,
|
||||||
@@ -598,5 +1276,10 @@ pub fn public_router() -> Router {
|
|||||||
portal_media,
|
portal_media,
|
||||||
"client-media",
|
"client-media",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/client/{token}/media/{media_id}/thumbnail",
|
||||||
|
portal_media_thumbnail,
|
||||||
|
"client-media-thumbnail",
|
||||||
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
+873
-14
@@ -1,12 +1,403 @@
|
|||||||
|
use std::fmt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
|
||||||
|
use aws_sdk_s3::presigning::PresigningConfig;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use cot::db::{Database, Model};
|
||||||
use cot::response::Response;
|
use cot::response::Response;
|
||||||
use cot::{Body, StatusCode};
|
use cot::{Body, StatusCode};
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use crate::models::Setting;
|
||||||
|
|
||||||
|
const THUMBNAIL_MAX_DIMENSION: u32 = 360;
|
||||||
|
const THUMBNAIL_JPEG_QUALITY: u8 = 70;
|
||||||
|
pub const VIDEO_PREVIEW_FRAME_COUNT: usize = 4;
|
||||||
|
const VIDEO_PREVIEW_FRAME_WIDTH: u32 = 320;
|
||||||
|
const VIDEO_PREVIEW_FRAME_HEIGHT: u32 = 240;
|
||||||
|
const PRESIGNED_URL_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
|
||||||
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
||||||
const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR";
|
const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR";
|
||||||
|
|
||||||
|
pub const R2_ENABLED_KEY: &str = "r2_enabled";
|
||||||
|
pub const R2_ACCOUNT_ID_KEY: &str = "r2_account_id";
|
||||||
|
pub const R2_BUCKET_KEY: &str = "r2_bucket";
|
||||||
|
pub const R2_ACCESS_KEY_ID_KEY: &str = "r2_access_key_id";
|
||||||
|
pub const R2_SECRET_ACCESS_KEY_KEY: &str = "r2_secret_access_key";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct StorageError(String);
|
||||||
|
|
||||||
|
impl StorageError {
|
||||||
|
fn new(context: &str, error: impl fmt::Display) -> Self {
|
||||||
|
Self(format!("{context}: {error}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for StorageError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for StorageError {}
|
||||||
|
|
||||||
|
pub type StorageResult<T> = Result<T, StorageError>;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct R2Config {
|
||||||
|
pub account_id: String,
|
||||||
|
pub bucket: String,
|
||||||
|
pub access_key_id: String,
|
||||||
|
pub secret_access_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl R2Config {
|
||||||
|
pub fn from_settings(settings: &[Setting]) -> Option<Self> {
|
||||||
|
let value = |key: &str| {
|
||||||
|
settings
|
||||||
|
.iter()
|
||||||
|
.find(|setting| setting.key == key)
|
||||||
|
.map(|setting| setting.value.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let config = Self {
|
||||||
|
account_id: value(R2_ACCOUNT_ID_KEY),
|
||||||
|
bucket: value(R2_BUCKET_KEY),
|
||||||
|
access_key_id: value(R2_ACCESS_KEY_ID_KEY),
|
||||||
|
secret_access_key: value(R2_SECRET_ACCESS_KEY_KEY),
|
||||||
|
};
|
||||||
|
config.is_valid().then_some(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fields_are_valid(
|
||||||
|
account_id: &str,
|
||||||
|
bucket: &str,
|
||||||
|
access_key_id: &str,
|
||||||
|
secret_access_key: &str,
|
||||||
|
) -> bool {
|
||||||
|
let account_id = account_id.trim();
|
||||||
|
let bucket = bucket.trim();
|
||||||
|
account_id.len() == 32
|
||||||
|
&& account_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
&& (3..=63).contains(&bucket.len())
|
||||||
|
&& !bucket.starts_with('-')
|
||||||
|
&& !bucket.ends_with('-')
|
||||||
|
&& bucket
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||||
|
&& !access_key_id.trim().is_empty()
|
||||||
|
&& !secret_access_key.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
Self::fields_are_valid(
|
||||||
|
&self.account_id,
|
||||||
|
&self.bucket,
|
||||||
|
&self.access_key_id,
|
||||||
|
&self.secret_access_key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(&self) -> String {
|
||||||
|
format!("https://{}.r2.cloudflarestorage.com", self.account_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct R2Storage {
|
||||||
|
client: Client,
|
||||||
|
bucket: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl R2Storage {
|
||||||
|
fn new(config: R2Config) -> Self {
|
||||||
|
let endpoint = config.endpoint();
|
||||||
|
let bucket = config.bucket.clone();
|
||||||
|
let credentials = Credentials::new(
|
||||||
|
config.access_key_id,
|
||||||
|
config.secret_access_key,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"web-petting-r2",
|
||||||
|
);
|
||||||
|
let sdk_config = aws_sdk_s3::Config::builder()
|
||||||
|
.behavior_version(BehaviorVersion::latest())
|
||||||
|
.endpoint_url(endpoint)
|
||||||
|
.credentials_provider(credentials)
|
||||||
|
.region(Region::new("auto"))
|
||||||
|
.build();
|
||||||
|
Self {
|
||||||
|
client: Client::from_conf(sdk_config),
|
||||||
|
bucket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put(&self, db_path: &str, data: &[u8]) -> StorageResult<()> {
|
||||||
|
self.put_stream(db_path, ByteStream::from(data.to_vec()))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_stream(&self, db_path: &str, body: ByteStream) -> StorageResult<()> {
|
||||||
|
self.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.content_type(content_type_for_path(db_path))
|
||||||
|
.cache_control("private, max-age=21600")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to upload object to R2", error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, db_path: &str) -> StorageResult<Vec<u8>> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to download object from R2", error))?;
|
||||||
|
let bytes = response
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to read R2 response body", error))?;
|
||||||
|
Ok(bytes.into_bytes().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to download object from R2", error))?;
|
||||||
|
let mut reader = response.body.into_async_read();
|
||||||
|
let mut file = tokio::fs::File::create(destination)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to create temporary media file", error))?;
|
||||||
|
tokio::io::copy(&mut reader, &mut file)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to stream R2 object to disk", error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, db_path: &str) -> StorageResult<()> {
|
||||||
|
self.client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to delete object from R2", error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exists(&self, db_path: &str) -> StorageResult<bool> {
|
||||||
|
match self
|
||||||
|
.client
|
||||||
|
.head_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(error)
|
||||||
|
if error
|
||||||
|
.as_service_error()
|
||||||
|
.is_some_and(|service_error| service_error.is_not_found()) =>
|
||||||
|
{
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
Err(error) => Err(StorageError::new("failed to inspect R2 object", error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn presigned_get_url(&self, db_path: &str) -> StorageResult<String> {
|
||||||
|
let config = PresigningConfig::expires_in(PRESIGNED_URL_TTL)
|
||||||
|
.map_err(|error| StorageError::new("failed to configure R2 signed URL", error))?;
|
||||||
|
let request = self
|
||||||
|
.client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.presigned(config)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to sign R2 object URL", error))?;
|
||||||
|
Ok(request.uri().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum Storage {
|
||||||
|
Local,
|
||||||
|
R2(R2Storage),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Storage {
|
||||||
|
pub async fn load(db: &Database) -> cot::Result<Self> {
|
||||||
|
let settings = Setting::objects().all(db).await?;
|
||||||
|
let enabled = settings
|
||||||
|
.iter()
|
||||||
|
.find(|setting| setting.key == R2_ENABLED_KEY)
|
||||||
|
.map(|setting| setting.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !enabled {
|
||||||
|
return Ok(Self::Local);
|
||||||
|
}
|
||||||
|
match R2Config::from_settings(&settings) {
|
||||||
|
Some(config) => Ok(Self::R2(R2Storage::new(config))),
|
||||||
|
None => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "uploads",
|
||||||
|
"R2 is enabled but its configuration is incomplete; using local storage"
|
||||||
|
);
|
||||||
|
Ok(Self::Local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load configured R2 even when it is not enabled yet. This lets the
|
||||||
|
/// one-time migration run before the site is switched away from local storage.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn load_configured_r2(db: &Database) -> cot::Result<Self> {
|
||||||
|
let settings = Setting::objects().all(db).await?;
|
||||||
|
let config = R2Config::from_settings(&settings).ok_or_else(|| {
|
||||||
|
cot::Error::internal("R2 settings are missing or invalid".to_string())
|
||||||
|
})?;
|
||||||
|
Ok(Self::R2(R2Storage::new(config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_r2(&self) -> bool {
|
||||||
|
matches!(self, Self::R2(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_logical_dir(&self, db_dir: &str) -> StorageResult<()> {
|
||||||
|
if matches!(self, Self::Local) {
|
||||||
|
tokio::fs::create_dir_all(resolve_db_path(db_dir))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to create upload directory", error))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write(&self, db_path: &str, data: &[u8]) -> StorageResult<()> {
|
||||||
|
match self {
|
||||||
|
Self::Local => {
|
||||||
|
let physical_path = resolve_db_path(db_path);
|
||||||
|
if let Some(parent) = physical_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||||
|
StorageError::new("failed to create upload directory", error)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(physical_path, data)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to write uploaded file", error))
|
||||||
|
}
|
||||||
|
Self::R2(storage) => storage.put(db_path, data).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read(&self, db_path: &str) -> StorageResult<Vec<u8>> {
|
||||||
|
match self {
|
||||||
|
Self::Local => tokio::fs::read(resolve_db_path(db_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to read uploaded file", error)),
|
||||||
|
Self::R2(storage) => storage.get(db_path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(&self, db_path: &str) -> StorageResult<()> {
|
||||||
|
match self {
|
||||||
|
Self::Local => match tokio::fs::remove_file(resolve_db_path(db_path)).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(StorageError::new("failed to remove uploaded file", error)),
|
||||||
|
},
|
||||||
|
Self::R2(storage) => storage.delete(db_path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exists(&self, db_path: &str) -> StorageResult<bool> {
|
||||||
|
match self {
|
||||||
|
Self::Local => tokio::fs::try_exists(resolve_db_path(db_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to inspect uploaded file", error)),
|
||||||
|
Self::R2(storage) => storage.exists(db_path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn public_url(&self, db_path: &str, local_fallback: String) -> StorageResult<String> {
|
||||||
|
match self {
|
||||||
|
Self::Local => Ok(local_fallback),
|
||||||
|
Self::R2(storage) => storage.presigned_get_url(db_path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
|
||||||
|
match self {
|
||||||
|
Self::Local => {
|
||||||
|
tokio::fs::copy(resolve_db_path(db_path), destination)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
StorageError::new("failed to copy local media to temporary file", error)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Self::R2(storage) => storage.download_to_path(db_path, destination).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a local PVC file into R2 without loading large videos into memory.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn upload_local_copy(&self, db_path: &str, local_path: &Path) -> StorageResult<()> {
|
||||||
|
let Self::R2(storage) = self else {
|
||||||
|
return Err(StorageError::new(
|
||||||
|
"failed to migrate local file",
|
||||||
|
"R2 storage is not configured",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let metadata = tokio::fs::metadata(local_path).await.map_err(|error| {
|
||||||
|
StorageError::new(
|
||||||
|
&format!(
|
||||||
|
"failed to inspect local migration file {}",
|
||||||
|
local_path.display()
|
||||||
|
),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !metadata.is_file() || metadata.len() == 0 {
|
||||||
|
return Err(StorageError::new(
|
||||||
|
"failed to open local file for migration",
|
||||||
|
format!("{} is not a non-empty file", local_path.display()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let body = ByteStream::from_path(local_path).await.map_err(|error| {
|
||||||
|
StorageError::new(
|
||||||
|
&format!(
|
||||||
|
"failed to open local file for migration {}",
|
||||||
|
local_path.display()
|
||||||
|
),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
storage.put_stream(db_path, body).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn media_dir(client_id: i64, visit_id: i64) -> String {
|
pub fn media_dir(client_id: i64, visit_id: i64) -> String {
|
||||||
format!("{DEFAULT_UPLOAD_DIR}/{client_id}/{visit_id}")
|
format!("{DEFAULT_UPLOAD_DIR}/{client_id}/{visit_id}")
|
||||||
}
|
}
|
||||||
@@ -41,20 +432,416 @@ pub fn resolved_display_path(db_path: &str) -> String {
|
|||||||
resolve_db_path(db_path).display().to_string()
|
resolve_db_path(db_path).display().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_logical_dir(db_dir: &str) -> std::io::Result<()> {
|
pub fn object_key(db_path: &str) -> String {
|
||||||
tokio::fs::create_dir_all(resolve_db_path(db_dir)).await
|
db_path
|
||||||
|
.replace('\\', "/")
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn write_db_file(db_path: &str, data: &[u8]) -> std::io::Result<()> {
|
pub fn thumbnail_db_path(db_path: &str) -> String {
|
||||||
let physical_path = resolve_db_path(db_path);
|
match db_path.rsplit_once('.') {
|
||||||
if let Some(parent) = physical_path.parent() {
|
Some((stem, _)) => format!("{stem}.thumb.jpg"),
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
None => format!("{db_path}.thumb.jpg"),
|
||||||
}
|
}
|
||||||
tokio::fs::write(physical_path, data).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_db_file(db_path: &str) -> std::io::Result<Vec<u8>> {
|
pub fn normalized_video_db_path(db_path: &str) -> String {
|
||||||
tokio::fs::read(resolve_db_path(db_path)).await
|
match db_path.rsplit_once('.') {
|
||||||
|
Some((stem, _)) if stem.ends_with(".web") => format!("{stem}.mp4"),
|
||||||
|
Some((stem, _)) => format!("{stem}.web.mp4"),
|
||||||
|
None => format!("{db_path}.web.mp4"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn supports_thumbnail(db_path: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
db_path
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str(),
|
||||||
|
"jpg" | "jpeg" | "png" | "webp"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn supports_video_preview(db_path: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
db_path
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str(),
|
||||||
|
"mp4" | "mov" | "avi" | "mkv" | "webm"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content_type_for_path(path: &str) -> &'static str {
|
||||||
|
match path
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"png" => "image/png",
|
||||||
|
"heic" | "heif" => "image/heic",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"mp4" => "video/mp4",
|
||||||
|
"mov" => "video/quicktime",
|
||||||
|
"avi" => "video/x-msvideo",
|
||||||
|
"mkv" => "video/x-matroska",
|
||||||
|
"webm" => "video/webm",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_thumbnail(data: &[u8]) -> StorageResult<Vec<u8>> {
|
||||||
|
let image = image::load_from_memory(data)
|
||||||
|
.map_err(|error| StorageError::new("failed to decode image for thumbnail", error))?;
|
||||||
|
let thumbnail = image.thumbnail(THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION);
|
||||||
|
let rgb = thumbnail.to_rgb8();
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
let mut encoder = JpegEncoder::new_with_quality(&mut encoded, THUMBNAIL_JPEG_QUALITY);
|
||||||
|
encoder
|
||||||
|
.encode_image(&rgb)
|
||||||
|
.map_err(|error| StorageError::new("failed to encode image thumbnail", error))?;
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_thumbnail(storage: &Storage, db_path: &str) -> StorageResult<String> {
|
||||||
|
let thumbnail_path = thumbnail_db_path(db_path);
|
||||||
|
if storage.exists(&thumbnail_path).await? {
|
||||||
|
return Ok(thumbnail_path);
|
||||||
|
}
|
||||||
|
let data = storage.read(db_path).await?;
|
||||||
|
write_thumbnail(storage, db_path, &data).await?;
|
||||||
|
Ok(thumbnail_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_thumbnail(
|
||||||
|
storage: &Storage,
|
||||||
|
db_path: &str,
|
||||||
|
source_data: &[u8],
|
||||||
|
) -> StorageResult<String> {
|
||||||
|
let thumbnail_path = thumbnail_db_path(db_path);
|
||||||
|
let encoded = encode_thumbnail(source_data)?;
|
||||||
|
storage.write(&thumbnail_path, &encoded).await?;
|
||||||
|
Ok(thumbnail_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a missing thumbnail from the local PVC even when R2 is enabled.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn ensure_local_thumbnail(db_path: &str) -> StorageResult<String> {
|
||||||
|
let thumbnail_path = thumbnail_db_path(db_path);
|
||||||
|
if tokio::fs::try_exists(resolve_db_path(&thumbnail_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to inspect local thumbnail", error))?
|
||||||
|
{
|
||||||
|
return Ok(thumbnail_path);
|
||||||
|
}
|
||||||
|
let data = tokio::fs::read(resolve_db_path(db_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to read local image", error))?;
|
||||||
|
let encoded = encode_thumbnail(&data)?;
|
||||||
|
let physical_path = resolve_db_path(&thumbnail_path);
|
||||||
|
if let Some(parent) = physical_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to create thumbnail directory", error))?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(physical_path, encoded)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to write local thumbnail", error))?;
|
||||||
|
Ok(thumbnail_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MediaDeliveryPaths {
|
||||||
|
pub media_path: String,
|
||||||
|
pub thumbnail_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_video_workspace() -> StorageResult<PathBuf> {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"web-petting-video-preview-{}",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
tokio::fs::create_dir(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to create video workspace", error))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_media_command(command: &mut Command, context: &str) -> StorageResult<()> {
|
||||||
|
let output = command
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new(context, error))?;
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
Err(StorageError::new(
|
||||||
|
context,
|
||||||
|
stderr
|
||||||
|
.trim()
|
||||||
|
.lines()
|
||||||
|
.last()
|
||||||
|
.unwrap_or("unknown ffmpeg error"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn video_transcode_command(source: &Path, destination: &Path, include_audio: bool) -> Command {
|
||||||
|
let mut command = Command::new("ffmpeg");
|
||||||
|
command
|
||||||
|
.args([
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-ignore_unknown",
|
||||||
|
"-i",
|
||||||
|
])
|
||||||
|
.arg(source)
|
||||||
|
.args(["-map", "0:v:0"]);
|
||||||
|
if include_audio {
|
||||||
|
command.args(["-map", "0:a:0?"]);
|
||||||
|
} else {
|
||||||
|
command.arg("-an");
|
||||||
|
}
|
||||||
|
command
|
||||||
|
.args(["-map_metadata", "-1", "-map_chapters", "-1", "-sn", "-dn"])
|
||||||
|
.args([
|
||||||
|
"-vf",
|
||||||
|
"scale=w='min(1280,iw)':h='min(1280,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"fast",
|
||||||
|
"-crf",
|
||||||
|
"26",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-tag:v",
|
||||||
|
"avc1",
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-b:a",
|
||||||
|
"96k",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
"-threads",
|
||||||
|
"2",
|
||||||
|
])
|
||||||
|
.arg(destination);
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transcode_video_for_browser(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||||
|
let mut with_audio = video_transcode_command(source, destination, true);
|
||||||
|
match run_media_command(&mut with_audio, "failed to transcode video with ffmpeg").await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(audio_error) => {
|
||||||
|
// Some iPhone MOV files contain a second, audio-like service stream
|
||||||
|
// with no decoder. Preserve audio when possible, but keep the video
|
||||||
|
// usable by retrying without that stream.
|
||||||
|
let mut video_only = video_transcode_command(source, destination, false);
|
||||||
|
run_media_command(
|
||||||
|
&mut video_only,
|
||||||
|
&format!("{audio_error}; video-only fallback also failed"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn video_duration(source: &Path) -> StorageResult<f64> {
|
||||||
|
let mut command = Command::new("ffprobe");
|
||||||
|
command
|
||||||
|
.args([
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
|
])
|
||||||
|
.arg(source);
|
||||||
|
let output = command
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to inspect video with ffprobe", error))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(StorageError::new(
|
||||||
|
"failed to inspect video with ffprobe",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let duration = String::from_utf8_lossy(&output.stdout)
|
||||||
|
.trim()
|
||||||
|
.parse::<f64>()
|
||||||
|
.map_err(|error| StorageError::new("failed to parse video duration", error))?;
|
||||||
|
if !duration.is_finite() || duration <= 0.0 {
|
||||||
|
return Err(StorageError::new(
|
||||||
|
"failed to inspect video duration",
|
||||||
|
"duration is zero or invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn validate_video_file(source: &Path) -> StorageResult<()> {
|
||||||
|
video_duration(source).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_video_sprite(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||||
|
let duration = video_duration(source).await?;
|
||||||
|
let frame_times = [0.08, 0.34, 0.60, 0.86].map(|position| duration * position);
|
||||||
|
let mut command = Command::new("ffmpeg");
|
||||||
|
command.args(["-hide_banner", "-loglevel", "error", "-y"]);
|
||||||
|
for timestamp in frame_times {
|
||||||
|
command
|
||||||
|
.arg("-ss")
|
||||||
|
.arg(format!("{timestamp:.3}"))
|
||||||
|
.arg("-i")
|
||||||
|
.arg(source);
|
||||||
|
}
|
||||||
|
let frame_filter = format!(
|
||||||
|
"scale={VIDEO_PREVIEW_FRAME_WIDTH}:{VIDEO_PREVIEW_FRAME_HEIGHT}:force_original_aspect_ratio=decrease,pad={VIDEO_PREVIEW_FRAME_WIDTH}:{VIDEO_PREVIEW_FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1"
|
||||||
|
);
|
||||||
|
let filter = format!(
|
||||||
|
"[0:v]{frame_filter}[v0];[1:v]{frame_filter}[v1];[2:v]{frame_filter}[v2];[3:v]{frame_filter}[v3];[v0][v1][v2][v3]hstack=inputs={VIDEO_PREVIEW_FRAME_COUNT}[out]"
|
||||||
|
);
|
||||||
|
command
|
||||||
|
.arg("-filter_complex")
|
||||||
|
.arg(filter)
|
||||||
|
.args(["-map", "[out]", "-frames:v", "1", "-q:v", "4"])
|
||||||
|
.arg(destination);
|
||||||
|
run_media_command(&mut command, "failed to create video preview with ffmpeg").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_video_thumbnail_file(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||||
|
create_video_sprite(source, destination).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the compact browser MP4 and its four-frame JPEG sprite on disk.
|
||||||
|
/// The source path is only read and is never changed or removed.
|
||||||
|
pub async fn create_compact_video_files(
|
||||||
|
source: &Path,
|
||||||
|
video_destination: &Path,
|
||||||
|
thumbnail_destination: &Path,
|
||||||
|
) -> StorageResult<()> {
|
||||||
|
let result = async {
|
||||||
|
transcode_video_for_browser(source, video_destination).await?;
|
||||||
|
create_video_sprite(video_destination, thumbnail_destination).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = tokio::fs::remove_file(video_destination).await;
|
||||||
|
let _ = tokio::fs::remove_file(thumbnail_destination).await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an incoming upload and store only the compact browser MP4 and its
|
||||||
|
/// JPEG sprite. The original upload bytes are never written to storage.
|
||||||
|
pub async fn write_compact_video(
|
||||||
|
storage: &Storage,
|
||||||
|
db_path: &str,
|
||||||
|
source_extension: &str,
|
||||||
|
source_data: &[u8],
|
||||||
|
) -> StorageResult<()> {
|
||||||
|
let workspace = create_video_workspace().await?;
|
||||||
|
let source = workspace.join(format!("source.{source_extension}"));
|
||||||
|
let compact_video = workspace.join("video.mp4");
|
||||||
|
let thumbnail = workspace.join("preview.jpg");
|
||||||
|
let result = async {
|
||||||
|
tokio::fs::write(&source, source_data)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to write temporary video", error))?;
|
||||||
|
create_compact_video_files(&source, &compact_video, &thumbnail).await?;
|
||||||
|
let video_data = tokio::fs::read(&compact_video)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to read transcoded video", error))?;
|
||||||
|
let thumbnail_data = tokio::fs::read(&thumbnail)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to read video preview", error))?;
|
||||||
|
let thumbnail_path = thumbnail_db_path(db_path);
|
||||||
|
storage.write(db_path, &video_data).await?;
|
||||||
|
storage.write(&thumbnail_path, &thumbnail_data).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let _ = tokio::fs::remove_dir_all(&workspace).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and persist a missing video sprite. In R2 mode the compact source is
|
||||||
|
/// downloaded only once; subsequent page loads reuse the generated JPEG.
|
||||||
|
pub async fn ensure_video_thumbnail(storage: &Storage, db_path: &str) -> StorageResult<String> {
|
||||||
|
let thumbnail_path = thumbnail_db_path(db_path);
|
||||||
|
if storage.exists(&thumbnail_path).await? {
|
||||||
|
return Ok(thumbnail_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace = create_video_workspace().await?;
|
||||||
|
let extension = db_path.rsplit('.').next().unwrap_or("mp4");
|
||||||
|
let source = workspace.join(format!("source.{extension}"));
|
||||||
|
let thumbnail = workspace.join("preview.jpg");
|
||||||
|
let result = async {
|
||||||
|
storage.download_to_path(db_path, &source).await?;
|
||||||
|
create_video_thumbnail_file(&source, &thumbnail).await?;
|
||||||
|
let data = tokio::fs::read(&thumbnail)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to read video preview", error))?;
|
||||||
|
storage.write(&thumbnail_path, &data).await?;
|
||||||
|
Ok(thumbnail_path.clone())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let _ = tokio::fs::remove_dir_all(&workspace).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_media_delivery_paths(
|
||||||
|
storage: &Storage,
|
||||||
|
file_type: &str,
|
||||||
|
db_path: &str,
|
||||||
|
) -> StorageResult<MediaDeliveryPaths> {
|
||||||
|
if file_type == "video" && supports_video_preview(db_path) {
|
||||||
|
let thumbnail_path = ensure_video_thumbnail(storage, db_path).await?;
|
||||||
|
return Ok(MediaDeliveryPaths {
|
||||||
|
media_path: db_path.to_string(),
|
||||||
|
thumbnail_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let thumbnail_path = if file_type == "photo" && supports_thumbnail(db_path) {
|
||||||
|
ensure_thumbnail(storage, db_path).await?
|
||||||
|
} else {
|
||||||
|
db_path.to_string()
|
||||||
|
};
|
||||||
|
Ok(MediaDeliveryPaths {
|
||||||
|
media_path: db_path.to_string(),
|
||||||
|
thumbnail_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn media_delivery_paths(file_type: &str, db_path: &str) -> MediaDeliveryPaths {
|
||||||
|
let thumbnail_path = if (file_type == "photo" && supports_thumbnail(db_path))
|
||||||
|
|| (file_type == "video" && supports_video_preview(db_path))
|
||||||
|
{
|
||||||
|
thumbnail_db_path(db_path)
|
||||||
|
} else {
|
||||||
|
db_path.to_string()
|
||||||
|
};
|
||||||
|
MediaDeliveryPaths {
|
||||||
|
media_path: db_path.to_string(),
|
||||||
|
thumbnail_path,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ByteRange {
|
enum ByteRange {
|
||||||
@@ -112,8 +899,8 @@ fn parse_byte_range(header: Option<&str>, file_len: u64) -> ByteRange {
|
|||||||
ByteRange::Partial { start, end }
|
ByteRange::Partial { start, end }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a file into an HTTP response, honoring a single `Range: bytes=...` request.
|
/// Read a local file into an HTTP response, honoring one `Range: bytes=...` request.
|
||||||
pub async fn ranged_file_response(
|
pub async fn ranged_local_file_response(
|
||||||
db_path: &str,
|
db_path: &str,
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
range_header: Option<&str>,
|
range_header: Option<&str>,
|
||||||
@@ -174,13 +961,33 @@ pub async fn ranged_file_response(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> {
|
pub fn storage_error(error: StorageError) -> cot::Error {
|
||||||
tokio::fs::remove_file(resolve_db_path(db_path)).await
|
cot::Error::internal(error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ByteRange, parse_byte_range};
|
use super::{
|
||||||
|
ByteRange, R2Config, R2Storage, normalized_video_db_path, object_key, parse_byte_range,
|
||||||
|
supports_video_preview, thumbnail_db_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_stable_normalized_video_paths() {
|
||||||
|
assert_eq!(
|
||||||
|
normalized_video_db_path("uploads/1/report.mov"),
|
||||||
|
"uploads/1/report.web.mp4"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalized_video_db_path("uploads/1/report.web.mp4"),
|
||||||
|
"uploads/1/report.web.mp4"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
thumbnail_db_path("uploads/1/report.web.mp4"),
|
||||||
|
"uploads/1/report.web.thumb.jpg"
|
||||||
|
);
|
||||||
|
assert!(supports_video_preview("report.MOV"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_byte_ranges() {
|
fn parses_byte_ranges() {
|
||||||
@@ -201,4 +1008,56 @@ mod tests {
|
|||||||
ByteRange::Unsatisfiable
|
ByteRange::Unsatisfiable
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_r2_fields() {
|
||||||
|
assert!(R2Config::fields_are_valid(
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
"pet-media",
|
||||||
|
"access-key",
|
||||||
|
"secret-key",
|
||||||
|
));
|
||||||
|
assert!(!R2Config::fields_are_valid(
|
||||||
|
"not-an-account",
|
||||||
|
"pet-media",
|
||||||
|
"access-key",
|
||||||
|
"secret-key",
|
||||||
|
));
|
||||||
|
assert!(!R2Config::fields_are_valid(
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
"Invalid_Bucket",
|
||||||
|
"access-key",
|
||||||
|
"secret-key",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_database_paths_to_object_keys() {
|
||||||
|
assert_eq!(object_key("uploads/1/photo.jpg"), "uploads/1/photo.jpg");
|
||||||
|
assert_eq!(
|
||||||
|
object_key("/data/uploads/photo.jpg"),
|
||||||
|
"data/uploads/photo.jpg"
|
||||||
|
);
|
||||||
|
assert_eq!(object_key("uploads\\1\\photo.jpg"), "uploads/1/photo.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signs_r2_urls_for_six_hours() {
|
||||||
|
let storage = R2Storage::new(R2Config {
|
||||||
|
account_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
bucket: "pet-media".to_string(),
|
||||||
|
access_key_id: "access-key".to_string(),
|
||||||
|
secret_access_key: "secret-key".to_string(),
|
||||||
|
});
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let url = runtime
|
||||||
|
.block_on(storage.presigned_get_url("uploads/1/photo.jpg"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(url.contains("X-Amz-Expires=21600"));
|
||||||
|
assert!(url.contains("uploads/1/photo.jpg"));
|
||||||
|
assert!(url.contains("pet-media"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use cot::db::{Database, Model, query};
|
||||||
|
use serde_json::json;
|
||||||
|
use web_push_native::jwt_simple::algorithms::ES256KeyPair;
|
||||||
|
use web_push_native::p256::PublicKey;
|
||||||
|
use web_push_native::{Auth, WebPushBuilder};
|
||||||
|
|
||||||
|
use crate::models::{Client, PushSubscription, Setting, Visit};
|
||||||
|
|
||||||
|
pub struct VapidConfig {
|
||||||
|
pub public_key: String,
|
||||||
|
private_key: String,
|
||||||
|
subject: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_key_pair(public_key: &str, private_key: &str) -> Option<(String, String)> {
|
||||||
|
let strip_assignment = |value: &str, name: &str| {
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.strip_prefix(&format!("{name}="))
|
||||||
|
.unwrap_or(value.trim())
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
let public_key = strip_assignment(public_key, "WEB_PETTING_VAPID_PUBLIC_KEY");
|
||||||
|
let private_key = strip_assignment(private_key, "WEB_PETTING_VAPID_PRIVATE_KEY");
|
||||||
|
let public_bytes = URL_SAFE_NO_PAD.decode(&public_key).ok()?;
|
||||||
|
let private_bytes = URL_SAFE_NO_PAD.decode(&private_key).ok()?;
|
||||||
|
|
||||||
|
if public_bytes.len() == 65 && public_bytes.first() == Some(&4) && private_bytes.len() == 32 {
|
||||||
|
return Some((public_key, private_key));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_config(db: &Database) -> Option<VapidConfig> {
|
||||||
|
let settings = Setting::objects().all(db).await.ok()?;
|
||||||
|
let value = |key: &str| {
|
||||||
|
settings
|
||||||
|
.iter()
|
||||||
|
.find(|setting| setting.key == key)
|
||||||
|
.map(|setting| setting.value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
};
|
||||||
|
let raw_public_key = value("vapid_public_key")?;
|
||||||
|
let raw_private_key = value("vapid_private_key")?;
|
||||||
|
let (public_key, private_key) = match normalize_key_pair(&raw_public_key, &raw_private_key) {
|
||||||
|
Some(keys) => keys,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("invalid VAPID configuration in database");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let subject_value =
|
||||||
|
value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string());
|
||||||
|
let subject = subject_value
|
||||||
|
.strip_prefix("WEB_PETTING_VAPID_SUBJECT=")
|
||||||
|
.unwrap_or(&subject_value)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
Some(VapidConfig {
|
||||||
|
public_key,
|
||||||
|
private_key,
|
||||||
|
subject,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn initialize(db: &Database) {
|
||||||
|
if load_config(db).await.is_some() {
|
||||||
|
tracing::info!("VAPID configuration loaded from database");
|
||||||
|
} else {
|
||||||
|
tracing::info!("VAPID configuration is not set; client Web Push is disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn notify_visit_completed(db: &Database, visit: &Visit) {
|
||||||
|
let setting_key = "client_notifications_enabled".to_string();
|
||||||
|
let enabled = query!(Setting, $key == setting_key)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|setting| setting.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
let Some(config) = load_config(db).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_id = visit.client_id.primary_key().unwrap();
|
||||||
|
let client = match query!(Client, $id == client_id).get(db).await {
|
||||||
|
Ok(Some(client)) => client,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let active = "active".to_string();
|
||||||
|
let subscriptions = match query!(PushSubscription, $status == active).all(db).await {
|
||||||
|
Ok(items) => items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| item.client_id.primary_key().unwrap() == client_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "failed to load Web Push subscriptions");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut visible_visits = match Visit::objects().all(db).await {
|
||||||
|
Ok(visits) => visits
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| {
|
||||||
|
item.client_id.primary_key().unwrap() == client_id
|
||||||
|
&& item.status != "cancelled"
|
||||||
|
&& item.status != "deleted"
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
visible_visits.sort_by(|a, b| {
|
||||||
|
b.visit_date
|
||||||
|
.cmp(&a.visit_date)
|
||||||
|
.then(b.time_start.cmp(&a.time_start))
|
||||||
|
});
|
||||||
|
let page = visible_visits
|
||||||
|
.iter()
|
||||||
|
.position(|item| item.id.unwrap() == visit.id.unwrap())
|
||||||
|
.map(|index| index / 10 + 1)
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
for mut subscription in subscriptions {
|
||||||
|
let is_ru = subscription.language == "ru";
|
||||||
|
let date = visit.visit_date.format("%d.%m.%Y");
|
||||||
|
let body = if is_ru {
|
||||||
|
format!("Визит {date} завершён. Нажмите для просмотра медиа и комментариев.")
|
||||||
|
} else {
|
||||||
|
format!("Visit {date} is complete. Click to view media and comments.")
|
||||||
|
};
|
||||||
|
let payload = json!({
|
||||||
|
"title": if is_ru { "Визит завершён" } else { "Visit completed" },
|
||||||
|
"body": body,
|
||||||
|
"url": format!("/client/{}?page={}#visit-{}", client.media_token, page, visit.id.unwrap()),
|
||||||
|
"tag": format!("visit-{}", visit.id.unwrap()),
|
||||||
|
});
|
||||||
|
|
||||||
|
match send(&subscription, payload.to_string().into_bytes(), &config).await {
|
||||||
|
Ok(status)
|
||||||
|
if status == reqwest::StatusCode::NOT_FOUND
|
||||||
|
|| status == reqwest::StatusCode::GONE =>
|
||||||
|
{
|
||||||
|
subscription.status = "archived".to_string();
|
||||||
|
subscription.updated_at = chrono::Utc::now().naive_utc();
|
||||||
|
if let Err(error) = subscription.save(db).await {
|
||||||
|
tracing::warn!(%error, "failed to archive expired Web Push subscription");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(status) if status.is_success() => {}
|
||||||
|
Ok(status) => tracing::warn!(%status, "Web Push gateway rejected notification"),
|
||||||
|
Err(error) => tracing::warn!(%error, "failed to send Web Push notification"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(
|
||||||
|
subscription: &PushSubscription,
|
||||||
|
content: Vec<u8>,
|
||||||
|
config: &VapidConfig,
|
||||||
|
) -> Result<reqwest::StatusCode, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let private = URL_SAFE_NO_PAD.decode(&config.private_key)?;
|
||||||
|
let key_pair = ES256KeyPair::from_bytes(&private)?;
|
||||||
|
let p256dh = URL_SAFE_NO_PAD.decode(&subscription.p256dh)?;
|
||||||
|
let auth = URL_SAFE_NO_PAD.decode(&subscription.auth)?;
|
||||||
|
let builder = WebPushBuilder::new(
|
||||||
|
subscription.endpoint.parse()?,
|
||||||
|
PublicKey::from_sec1_bytes(&p256dh)?,
|
||||||
|
Auth::clone_from_slice(&auth),
|
||||||
|
)
|
||||||
|
.with_vapid(&key_pair, &config.subject);
|
||||||
|
let request = builder.build(content)?;
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let response = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?
|
||||||
|
.request(parts.method, parts.uri.to_string())
|
||||||
|
.headers(parts.headers)
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(response.status())
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
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,50 +33,52 @@
|
|||||||
<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>
|
||||||
@@ -82,10 +89,12 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if client_status != "deleted" %}
|
{% if client_status != "deleted" %}
|
||||||
<form method="post" action="/admin/clients/{{ client_id }}/delete" onsubmit="return confirm('{{ t.clients_delete_confirm }}');" style="margin-top:0.75rem;">
|
<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>
|
<button type="submit" class="button is-danger is-outlined is-fullwidth">{{ t.clients_delete }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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" %}
|
||||||
@@ -48,22 +48,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- 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 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 &feedbacks %}
|
{% for fb in &feedbacks %}
|
||||||
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" class="item-card" style="border-left:3px solid #7c6cff;display:block;text-decoration:none;color:inherit;">
|
<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>
|
||||||
<span style="color:#999;font-size:0.8rem;">{{ fb.visit_date }}</span>
|
<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>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if feedback_total_pages > 1 %}
|
{% if feedback_total_pages > 1 %}
|
||||||
<div style="display:flex;justify-content:center;gap:0.5rem;margin-top:1rem;">
|
<div class="pagination-row">
|
||||||
{% if feedback_page > 1 %}
|
{% if feedback_page > 1 %}
|
||||||
<a href="/admin/?lang={{ lang.code() }}&page={{ feedback_page - 1 }}" class="button is-small is-light">«</a>
|
<a href="/admin/?lang={{ lang.code() }}&page={{ feedback_page - 1 }}" class="button is-small is-light">«</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+7
-109
@@ -6,119 +6,13 @@
|
|||||||
<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="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;
|
|
||||||
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.bottom-tabs::-webkit-scrollbar { display: none; }
|
|
||||||
.bottom-tabs a {
|
|
||||||
flex: 0 0 auto; min-width: 3.2rem; padding: 0 0.45rem;
|
|
||||||
display: flex; flex-direction: column; align-items: center;
|
|
||||||
justify-content: center; text-decoration: none; color: #999;
|
|
||||||
font-size: 0.6rem; font-weight: 600; gap: 0.1rem;
|
|
||||||
transition: color 0.15s; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.bottom-tabs a .tab-icon { font-size: 1.25rem; line-height: 1; }
|
|
||||||
.bottom-tabs a .tab-label { display: block; }
|
|
||||||
.bottom-tabs a.is-active { color: var(--accent); }
|
|
||||||
@media (max-width: 400px) {
|
|
||||||
.bottom-tabs a .tab-label { display: none; }
|
|
||||||
.bottom-tabs a { min-width: 2.8rem; padding: 0 0.3rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 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>
|
||||||
@@ -127,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">
|
||||||
@@ -164,6 +59,9 @@
|
|||||||
<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><span class="tab-label">{{ t.nav_users }}</span>
|
<span class="tab-icon">🔑</span><span class="tab-label">{{ t.nav_users }}</span>
|
||||||
</a>
|
</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 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><span class="tab-label">{{ t.nav_settings }}</span>
|
<span class="tab-icon">⚙️</span><span class="tab-label">{{ t.nav_settings }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+10
-20
@@ -6,31 +6,21 @@
|
|||||||
<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="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">
|
||||||
|
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||||
{% if !turnstile_site_key.is_empty() %}
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<style>
|
|
||||||
:root { color-scheme: light; }
|
|
||||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
|
||||||
.login-box { width: 100%; max-width: 380px; padding: 0 1rem; }
|
|
||||||
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
|
||||||
input, textarea, select, .input, .textarea, .select select {
|
|
||||||
background-color: #fff !important; color: #333 !important; border-color: #dbdbdb !important;
|
|
||||||
}
|
|
||||||
.label, label { color: #363636 !important; }
|
|
||||||
.notification { color: #333 !important; }
|
|
||||||
</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>
|
||||||
@@ -39,7 +29,7 @@
|
|||||||
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
|
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if auth_password_enabled %}
|
{% if auth_password_enabled %}
|
||||||
{% if auth_sso_enabled %}<hr style="margin:1rem 0;">{% endif %}
|
{% 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>
|
||||||
@@ -50,7 +40,7 @@
|
|||||||
<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() %}
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.75rem;"></div>
|
<div class="cf-turnstile turnstile-wrap" data-sitekey="{{ turnstile_site_key }}" data-theme="light"></div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|||||||
+27
-70
@@ -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,19 +21,23 @@
|
|||||||
</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.unwrap() }}" data-lightbox="photo">
|
<a href="{{ item.media.url }}" data-lightbox="photo">
|
||||||
<img src="/admin/uploads/{{ item.media.id.unwrap() }}" 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.unwrap() }}" data-lightbox="video">
|
<a href="{{ item.media.url }}" data-lightbox="video">
|
||||||
<div class="video-thumb">
|
<div class="video-thumb media-loading-frame{% if !item.media.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
||||||
<video src="/admin/uploads/{{ item.media.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
{% 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>
|
<span class="video-play">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -42,79 +46,32 @@
|
|||||||
<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.unwrap() }}/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 {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 160px;
|
|
||||||
background: #111;
|
|
||||||
}
|
|
||||||
.media-card .video-thumb video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.media-card .video-play {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
color: white;
|
|
||||||
font-size: 2.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
text-shadow: 0 1px 5px #000;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.media-info {
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
}
|
|
||||||
.media-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,14 +5,18 @@
|
|||||||
|
|
||||||
{% 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>
|
||||||
|
<h2>{{ t.media_upload_title }}</h2>
|
||||||
|
<p>{{ client_name }} · {{ visit_label }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="admin-section-body">
|
||||||
|
|
||||||
<form id="uploadForm" 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">
|
||||||
@@ -20,7 +24,8 @@
|
|||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="file" id="uploadFiles" 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" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
<p id="fileCount" class="field-help"></p>
|
||||||
|
<div id="uploadQueue" class="upload-queue"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -31,13 +36,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress -->
|
<!-- Progress -->
|
||||||
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
<div id="uploadProgress" class="upload-progress">
|
||||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
<div class="upload-progress-head">
|
||||||
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||||
<span id="uploadPercent">0%</span>
|
<span id="uploadPercent">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
<div class="upload-progress-track">
|
||||||
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
<div id="uploadBar" class="upload-progress-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,6 +54,7 @@
|
|||||||
var form = document.getElementById('uploadForm');
|
var form = document.getElementById('uploadForm');
|
||||||
var filesInput = document.getElementById('uploadFiles');
|
var filesInput = document.getElementById('uploadFiles');
|
||||||
var fileCount = document.getElementById('fileCount');
|
var fileCount = document.getElementById('fileCount');
|
||||||
|
var queue = document.getElementById('uploadQueue');
|
||||||
var progress = document.getElementById('uploadProgress');
|
var progress = document.getElementById('uploadProgress');
|
||||||
var bar = document.getElementById('uploadBar');
|
var bar = document.getElementById('uploadBar');
|
||||||
var percent = document.getElementById('uploadPercent');
|
var percent = document.getElementById('uploadPercent');
|
||||||
@@ -57,8 +63,40 @@
|
|||||||
|
|
||||||
filesInput.addEventListener('change', function() {
|
filesInput.addEventListener('change', function() {
|
||||||
var n = this.files.length;
|
var n = this.files.length;
|
||||||
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
fileCount.textContent = n > 0 ? ('{{ t.media_files_selected }}: ' + n) : '';
|
||||||
|
queue.replaceChildren();
|
||||||
|
Array.from(this.files).forEach(function(file) {
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.className = 'upload-queue-item';
|
||||||
|
var icon = document.createElement('span');
|
||||||
|
icon.className = 'upload-queue-icon';
|
||||||
|
icon.textContent = file.type.indexOf('video/') === 0 ? '🎬' : '🖼️';
|
||||||
|
var details = document.createElement('div');
|
||||||
|
var name = document.createElement('div');
|
||||||
|
name.className = 'upload-queue-name';
|
||||||
|
name.textContent = file.name;
|
||||||
|
var state = document.createElement('div');
|
||||||
|
state.className = 'upload-queue-state';
|
||||||
|
state.textContent = '0%';
|
||||||
|
var track = document.createElement('div');
|
||||||
|
track.className = 'upload-queue-track';
|
||||||
|
var itemBar = document.createElement('div');
|
||||||
|
itemBar.className = 'upload-queue-bar';
|
||||||
|
track.appendChild(itemBar);
|
||||||
|
details.append(name, state, track);
|
||||||
|
item.append(icon, details);
|
||||||
|
queue.appendChild(item);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateQueue(state, progressValue, processing) {
|
||||||
|
queue.querySelectorAll('.upload-queue-item').forEach(function(item) {
|
||||||
|
item.querySelector('.upload-queue-state').textContent = state;
|
||||||
|
var itemBar = item.querySelector('.upload-queue-bar');
|
||||||
|
itemBar.classList.toggle('is-processing', processing);
|
||||||
|
if (!processing) itemBar.style.width = progressValue + '%';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -69,7 +107,9 @@
|
|||||||
|
|
||||||
progress.style.display = 'block';
|
progress.style.display = 'block';
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Загрузка...';
|
submitBtn.textContent = '{{ t.media_upload_sending }}';
|
||||||
|
statusText.textContent = '{{ t.media_upload_sending }}';
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
bar.style.width = '0%';
|
bar.style.width = '0%';
|
||||||
percent.textContent = '0%';
|
percent.textContent = '0%';
|
||||||
|
|
||||||
@@ -78,24 +118,42 @@
|
|||||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
bar.style.width = pct + '%';
|
bar.style.width = pct + '%';
|
||||||
percent.textContent = pct + '%';
|
percent.textContent = pct + '%';
|
||||||
if (pct === 100) statusText.textContent = 'Обработка...';
|
updateQueue(pct + '%', pct, false);
|
||||||
|
if (pct === 100) {
|
||||||
|
statusText.textContent = '{{ t.media_upload_processing }}';
|
||||||
|
percent.textContent = '•••';
|
||||||
|
bar.classList.add('is-processing');
|
||||||
|
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xhr.upload.addEventListener('load', function() {
|
||||||
|
statusText.textContent = '{{ t.media_upload_processing }}';
|
||||||
|
percent.textContent = '•••';
|
||||||
|
bar.classList.add('is-processing');
|
||||||
|
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('load', function() {
|
xhr.addEventListener('load', function() {
|
||||||
if (xhr.status >= 200 && xhr.status < 400) {
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
bar.style.width = '100%';
|
bar.style.width = '100%';
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
percent.textContent = '100%';
|
percent.textContent = '100%';
|
||||||
statusText.textContent = 'Готово!';
|
statusText.textContent = '{{ t.media_upload_done }}';
|
||||||
|
updateQueue('{{ t.media_upload_done }}', 100, false);
|
||||||
setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300);
|
setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300);
|
||||||
} else {
|
} else {
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||||
|
updateQueue('Ошибка загрузки', 0, false);
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = '{{ t.media_upload }}';
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('error', function() {
|
xhr.addEventListener('error', function() {
|
||||||
statusText.textContent = 'Ошибка соединения';
|
bar.classList.remove('is-processing');
|
||||||
|
statusText.textContent = '{{ t.media_upload_connection_error }}';
|
||||||
|
updateQueue('{{ t.media_upload_connection_error }}', 0, false);
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = '{{ t.media_upload }}';
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
});
|
});
|
||||||
@@ -105,5 +163,7 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</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,40 +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-event.ev-completed .fc-event-title,
|
|
||||||
.fc-event.ev-completed .fc-list-event-title { text-decoration: line-through; opacity: 0.75; }
|
|
||||||
.fc-event.ev-cancelled .fc-event-title,
|
|
||||||
.fc-event.ev-cancelled .fc-list-event-title { text-decoration: line-through; }
|
|
||||||
.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; }
|
|
||||||
.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>
|
||||||
@@ -60,10 +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>
|
||||||
<div id="vmStatus" style="margin-bottom:1rem;"></div>
|
<div id="vmStatus" class="mb-4"></div>
|
||||||
<a id="vmEditLink" href="#" class="button is-primary is-fullwidth" style="font-size:1rem;font-weight:700;padding:0.65rem;">📋 {{ t.schedule_edit_title }}</a>
|
<a id="vmEditLink" href="#" class="button is-primary is-fullwidth">📋 {{ t.schedule_edit_title }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,7 +48,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
timeZone: '{{ timezone }}',
|
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'
|
||||||
},
|
},
|
||||||
@@ -98,7 +64,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
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;
|
||||||
|
|||||||
@@ -5,10 +5,18 @@
|
|||||||
|
|
||||||
{% 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">
|
||||||
@@ -45,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>
|
||||||
@@ -93,33 +101,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if let Some(fb) = visit.client_feedback.as_deref() %}
|
{% if let Some(fb) = visit.client_feedback.as_deref() %}
|
||||||
<div style="margin-bottom:1rem;">
|
<div class="mb-4">
|
||||||
<label class="label">{{ t.schedule_client_feedback }}</label>
|
<label class="label">{{ t.schedule_client_feedback }}</label>
|
||||||
<div style="background:#f0f0ff;border-radius:8px;padding:0.6rem 0.85rem;font-size:0.9rem;color:#4a4570;">{{ fb }}</div>
|
<div class="admin-note admin-note-info">{{ fb }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<hr style="margin:1rem 0;">
|
<hr>
|
||||||
|
|
||||||
<!-- Media -->
|
<!-- Media -->
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
<div class="section-inline-head">
|
||||||
<label class="label" style="margin:0;">{{ t.nav_media }}</label>
|
<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>
|
<button type="button" class="button is-info is-small is-outlined" onclick="document.getElementById('uploadModal').classList.add('is-open')">+ {{ t.media_upload }}</button>
|
||||||
</div>
|
</div>
|
||||||
{% if media.is_empty() %}
|
{% if media.is_empty() %}
|
||||||
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ t.media_empty }}</p>
|
<p class="empty-state mb-4">{{ t.media_empty }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="visit-media-grid">
|
<div class="visit-media-grid">
|
||||||
{% for m in &media %}
|
{% for m in &media %}
|
||||||
<div class="visit-media-item">
|
<div class="visit-media-item">
|
||||||
{% if m.file_type == "photo" %}
|
{% if m.file_type == "photo" %}
|
||||||
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="photo">
|
<a href="{{ m.url }}" data-lightbox="photo">
|
||||||
<img src="/admin/uploads/{{ m.id.unwrap() }}" alt="" loading="lazy">
|
<span class="photo-thumb media-loading-frame is-loading">
|
||||||
|
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="video">
|
<a href="{{ m.url }}" data-lightbox="video">
|
||||||
<div class="video-thumb-sm">
|
<div class="video-thumb-sm media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
||||||
<video src="/admin/uploads/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
{% 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>
|
<span class="video-play">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -135,7 +147,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<hr style="margin:1rem 0;">
|
<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>
|
||||||
@@ -143,18 +155,19 @@
|
|||||||
<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>
|
<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 %}
|
{% endfor %}
|
||||||
|
|
||||||
<hr style="margin:1rem 0;">
|
<hr>
|
||||||
<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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Upload Modal -->
|
<!-- Upload Modal -->
|
||||||
<div class="upload-modal-bg" id="uploadModal">
|
<div class="upload-modal-bg" id="uploadModal">
|
||||||
<div class="upload-modal">
|
<div class="upload-modal">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
<div class="modal-head">
|
||||||
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
<h3>{{ t.media_upload_title }}</h3>
|
||||||
<button type="button" id="uploadModalClose" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;">✕</button>
|
<button type="button" id="uploadModalClose" class="modal-close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="uploadForm" 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">
|
||||||
@@ -162,7 +175,8 @@
|
|||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="file" id="uploadFiles" 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" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
<p id="fileCount" class="field-help"></p>
|
||||||
|
<div id="uploadQueue" class="upload-queue"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.media_caption }}</label>
|
<label class="label">{{ t.media_caption }}</label>
|
||||||
@@ -172,13 +186,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress -->
|
<!-- Progress -->
|
||||||
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
<div id="uploadProgress" class="upload-progress">
|
||||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
<div class="upload-progress-head">
|
||||||
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||||
<span id="uploadPercent">0%</span>
|
<span id="uploadPercent">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
<div class="upload-progress-track">
|
||||||
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
<div id="uploadBar" class="upload-progress-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,118 +201,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.status-picker {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.status-btn {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.6rem 0.4rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background: #f5f5f5;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #777;
|
|
||||||
transition: all 0.15s;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
.status-btn-icon { font-size: 1.3rem; line-height: 1; }
|
|
||||||
.status-btn:hover { filter: brightness(0.95); }
|
|
||||||
|
|
||||||
.status-btn-scheduled.is-active { background: #dbeafe; border-color: #3b82f6; color: #1e40af; }
|
|
||||||
.status-btn-completed.is-active { background: #d1fae5; border-color: #22c55e; color: #15803d; }
|
|
||||||
.status-btn-cancelled.is-active { background: #fee2e2; border-color: #ef4444; color: #b91c1c; }
|
|
||||||
|
|
||||||
.status-btn-scheduled:not(.is-active):hover { background: #eff6ff; color: #3b82f6; }
|
|
||||||
.status-btn-completed:not(.is-active):hover { background: #f0fdf4; color: #22c55e; }
|
|
||||||
.status-btn-cancelled:not(.is-active):hover { background: #fff5f5; color: #ef4444; }
|
|
||||||
|
|
||||||
.visit-media-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.visit-media-item {
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.visit-media-item img {
|
|
||||||
width: 100%;
|
|
||||||
height: 80px;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.visit-media-item .video-thumb-sm {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 80px;
|
|
||||||
background: #111;
|
|
||||||
}
|
|
||||||
.visit-media-item .video-thumb-sm video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.visit-media-item .video-play {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
color: white;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
line-height: 1;
|
|
||||||
text-shadow: 0 1px 4px #000;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.visit-media-item .media-cap {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: #888;
|
|
||||||
padding: 0.2rem 0.4rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.visit-media-delete {
|
|
||||||
padding: 0.25rem 0.4rem 0.4rem;
|
|
||||||
}
|
|
||||||
.visit-media-delete .button {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
min-height: 1.65rem;
|
|
||||||
}
|
|
||||||
.upload-modal-bg {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.35);
|
|
||||||
z-index: 100;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.upload-modal-bg.is-open {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.upload-modal {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 420px;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Status picker
|
// Status picker
|
||||||
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||||
@@ -316,6 +218,7 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
|||||||
var form = document.getElementById('uploadForm');
|
var form = document.getElementById('uploadForm');
|
||||||
var filesInput = document.getElementById('uploadFiles');
|
var filesInput = document.getElementById('uploadFiles');
|
||||||
var fileCount = document.getElementById('fileCount');
|
var fileCount = document.getElementById('fileCount');
|
||||||
|
var queue = document.getElementById('uploadQueue');
|
||||||
var progress = document.getElementById('uploadProgress');
|
var progress = document.getElementById('uploadProgress');
|
||||||
var bar = document.getElementById('uploadBar');
|
var bar = document.getElementById('uploadBar');
|
||||||
var percent = document.getElementById('uploadPercent');
|
var percent = document.getElementById('uploadPercent');
|
||||||
@@ -336,8 +239,40 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
|||||||
// Show selected file count
|
// Show selected file count
|
||||||
filesInput.addEventListener('change', function() {
|
filesInput.addEventListener('change', function() {
|
||||||
var n = this.files.length;
|
var n = this.files.length;
|
||||||
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
fileCount.textContent = n > 0 ? ('{{ t.media_files_selected }}: ' + n) : '';
|
||||||
|
queue.replaceChildren();
|
||||||
|
Array.from(this.files).forEach(function(file) {
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.className = 'upload-queue-item';
|
||||||
|
var icon = document.createElement('span');
|
||||||
|
icon.className = 'upload-queue-icon';
|
||||||
|
icon.textContent = file.type.indexOf('video/') === 0 ? '🎬' : '🖼️';
|
||||||
|
var details = document.createElement('div');
|
||||||
|
var name = document.createElement('div');
|
||||||
|
name.className = 'upload-queue-name';
|
||||||
|
name.textContent = file.name;
|
||||||
|
var state = document.createElement('div');
|
||||||
|
state.className = 'upload-queue-state';
|
||||||
|
state.textContent = '0%';
|
||||||
|
var track = document.createElement('div');
|
||||||
|
track.className = 'upload-queue-track';
|
||||||
|
var itemBar = document.createElement('div');
|
||||||
|
itemBar.className = 'upload-queue-bar';
|
||||||
|
track.appendChild(itemBar);
|
||||||
|
details.append(name, state, track);
|
||||||
|
item.append(icon, details);
|
||||||
|
queue.appendChild(item);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateQueue(state, progressValue, processing) {
|
||||||
|
queue.querySelectorAll('.upload-queue-item').forEach(function(item) {
|
||||||
|
item.querySelector('.upload-queue-state').textContent = state;
|
||||||
|
var itemBar = item.querySelector('.upload-queue-bar');
|
||||||
|
itemBar.classList.toggle('is-processing', processing);
|
||||||
|
if (!processing) itemBar.style.width = progressValue + '%';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Submit via XHR for progress tracking
|
// Submit via XHR for progress tracking
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
@@ -350,7 +285,9 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
|||||||
// Show progress bar, disable submit
|
// Show progress bar, disable submit
|
||||||
progress.style.display = 'block';
|
progress.style.display = 'block';
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Загрузка...';
|
submitBtn.textContent = '{{ t.media_upload_sending }}';
|
||||||
|
statusText.textContent = '{{ t.media_upload_sending }}';
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
bar.style.width = '0%';
|
bar.style.width = '0%';
|
||||||
percent.textContent = '0%';
|
percent.textContent = '0%';
|
||||||
|
|
||||||
@@ -359,25 +296,43 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
|
|||||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
bar.style.width = pct + '%';
|
bar.style.width = pct + '%';
|
||||||
percent.textContent = pct + '%';
|
percent.textContent = pct + '%';
|
||||||
if (pct === 100) statusText.textContent = 'Обработка...';
|
updateQueue(pct + '%', pct, false);
|
||||||
|
if (pct === 100) {
|
||||||
|
statusText.textContent = '{{ t.media_upload_processing }}';
|
||||||
|
percent.textContent = '•••';
|
||||||
|
bar.classList.add('is-processing');
|
||||||
|
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xhr.upload.addEventListener('load', function() {
|
||||||
|
statusText.textContent = '{{ t.media_upload_processing }}';
|
||||||
|
percent.textContent = '•••';
|
||||||
|
bar.classList.add('is-processing');
|
||||||
|
updateQueue('{{ t.media_upload_processing }}', 100, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('load', function() {
|
xhr.addEventListener('load', function() {
|
||||||
if (xhr.status >= 200 && xhr.status < 400) {
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
bar.style.width = '100%';
|
bar.style.width = '100%';
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
percent.textContent = '100%';
|
percent.textContent = '100%';
|
||||||
statusText.textContent = 'Готово!';
|
statusText.textContent = '{{ t.media_upload_done }}';
|
||||||
|
updateQueue('{{ t.media_upload_done }}', 100, false);
|
||||||
// Reload page to show uploaded media
|
// Reload page to show uploaded media
|
||||||
setTimeout(function() { window.location.reload(); }, 300);
|
setTimeout(function() { window.location.reload(); }, 300);
|
||||||
} else {
|
} else {
|
||||||
|
bar.classList.remove('is-processing');
|
||||||
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||||
|
updateQueue('Ошибка загрузки', 0, false);
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = '{{ t.media_upload }}';
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('error', function() {
|
xhr.addEventListener('error', function() {
|
||||||
statusText.textContent = 'Ошибка соединения';
|
bar.classList.remove('is-processing');
|
||||||
|
statusText.textContent = '{{ t.media_upload_connection_error }}';
|
||||||
|
updateQueue('{{ t.media_upload_connection_error }}', 0, false);
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = '{{ t.media_upload }}';
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,15 @@
|
|||||||
|
|
||||||
{% 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">
|
||||||
|
<header class="admin-section-head">
|
||||||
|
<span class="admin-section-icon" aria-hidden="true">📅</span>
|
||||||
|
<div><h2>{{ t.schedule_new_title }}</h2></div>
|
||||||
|
</header>
|
||||||
|
<div class="admin-section-body">
|
||||||
<form method="post" action="/admin/schedule/create" id="visitForm">
|
<form method="post" action="/admin/schedule/create" id="visitForm">
|
||||||
|
|
||||||
<!-- Client -->
|
<!-- Client -->
|
||||||
@@ -94,85 +99,7 @@
|
|||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<style>
|
|
||||||
/* Time row */
|
|
||||||
.time-row { display:flex; align-items:center; gap:0.5rem; }
|
|
||||||
.time-block { display:flex; flex-direction:column; flex:1; }
|
|
||||||
.time-lbl { font-size:0.75rem; color:#888; margin-bottom:0.2rem; }
|
|
||||||
.time-sep { font-size:1.2rem; color:#aaa; padding-top:1.2rem; }
|
|
||||||
|
|
||||||
/* Calendar toolbar */
|
|
||||||
.cal-toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.5rem; }
|
|
||||||
|
|
||||||
|
|
||||||
/* Calendar container */
|
|
||||||
.sched-cal { background:#fafafa; border:1px solid #eee; border-radius:10px; overflow:hidden; }
|
|
||||||
|
|
||||||
.cal-nav { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 0.75rem; background:#fff; border-bottom:1px solid #eee; }
|
|
||||||
.cal-nav button { background:none; border:none; font-size:1.1rem; cursor:pointer; color:#6c63ff; padding:0.2rem 0.5rem; border-radius:4px; }
|
|
||||||
.cal-nav button:hover { background:#f0eeff; }
|
|
||||||
.cal-nav span { font-weight:700; font-size:1rem; color:#333; }
|
|
||||||
|
|
||||||
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
|
|
||||||
|
|
||||||
.cal-wday { text-align:center; font-size:0.72rem; font-weight:700; color:#aaa; padding:0.4rem 0; background:#fafafa; }
|
|
||||||
.cal-wday.is-weekend { color:#f0a0a0; }
|
|
||||||
|
|
||||||
.cal-day { text-align:center; padding:0.55rem 0.2rem; font-size:0.9rem; cursor:pointer; color:#333; border-radius:0; transition:background 0.1s; position:relative; user-select:none; -webkit-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 { font-weight:700; color:#6c63ff; }
|
|
||||||
.cal-day.is-selected { background:#6c63ff !important; color:#fff !important; border-radius:0; }
|
|
||||||
.cal-day.is-range-start { background:#a89cff !important; color:#fff !important; }
|
|
||||||
.cal-day.is-past { color:#ccc; }
|
|
||||||
|
|
||||||
/* Selected days list */
|
|
||||||
.day-row { display:flex; align-items:center; gap:0.4rem; padding:0.45rem 0; border-bottom:1px solid #f5f5f5; }
|
|
||||||
.day-row:last-child { border-bottom:none; }
|
|
||||||
.day-date { font-weight:600; font-size:0.85rem; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
.day-times { display:flex; align-items:center; gap:0.25rem; flex-shrink:0; }
|
|
||||||
.day-times .time-sep { color:#bbb; font-size:0.8rem; }
|
|
||||||
.day-rm { background:none; border:none; color:#ccc; cursor:pointer; font-size:1rem; padding:0.15rem 0.25rem; flex-shrink:0; line-height:1; }
|
|
||||||
.day-rm:hover { color:#e55; }
|
|
||||||
|
|
||||||
/* Time badge */
|
|
||||||
.time-badge-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #ede9ff;
|
|
||||||
color: #5b52d6;
|
|
||||||
border: 1.5px solid #c4beff;
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
min-width: 3.6rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.time-badge-wrap:focus-within {
|
|
||||||
border-color: #6c63ff;
|
|
||||||
background: #f0eeff;
|
|
||||||
}
|
|
||||||
.time-badge-label { pointer-events: none; z-index: 1; }
|
|
||||||
.time-badge-input {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px; /* prevent iOS zoom */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
+347
-135
@@ -5,168 +5,380 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
<h1>{{ t.settings_title }}</h1>
|
<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">
|
||||||
|
<span class="admin-section-icon" aria-hidden="true">📊</span>
|
||||||
|
<div>
|
||||||
|
<h2>{{ t.analytics_summary_title }}</h2>
|
||||||
|
<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>
|
||||||
|
<a class="button is-light" href="/admin/analytics?lang={{ lang.code() }}">{{ t.analytics_open_dashboard }} →</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2>
|
<style>
|
||||||
<div class="field">
|
.settings-analytics-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:14px; }
|
||||||
<label class="label">{{ t.settings_contact_info }}</label>
|
.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; }
|
||||||
<div class="control">
|
.settings-analytics-kpi .kpi-value { font-size:1.6rem; font-weight:700; line-height:1.1; }
|
||||||
<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 %}">
|
.settings-analytics-kpi .kpi-label { font-size:.78rem; color:#777; margin-top:2px; }
|
||||||
</div>
|
</style>
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.settings_pricing_info }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.settings_seo_keywords }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3"
|
|
||||||
style="resize:vertical;"
|
|
||||||
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
|
||||||
</div>
|
|
||||||
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details style="margin-top:1.5rem;">
|
<form id="settingsForm" class="admin-form" method="post" action="/admin/settings/save">
|
||||||
<summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
|
<section class="admin-section">
|
||||||
{{ t.settings_section_advanced }}
|
<header class="admin-section-head">
|
||||||
</summary>
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div style="margin-top:1rem;">
|
<section class="admin-section">
|
||||||
<h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
|
<header class="admin-section-head">
|
||||||
<div class="field">
|
<span class="admin-section-icon" aria-hidden="true">☁️</span>
|
||||||
<label class="label">{{ t.settings_site_domain }}</label>
|
<div>
|
||||||
<div class="control">
|
<div class="admin-title-row">
|
||||||
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
<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>
|
</div>
|
||||||
|
<p>{{ t.settings_r2_help }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
</header>
|
||||||
<label class="label">{{ t.settings_timezone }}</label>
|
<div class="admin-section-body">
|
||||||
<div class="control">
|
<label class="admin-toggle" for="r2Enabled">
|
||||||
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
<input id="r2Enabled" type="checkbox" name="r2_enabled" value="true"{% if r2_enabled_checked %} checked{% endif %}>
|
||||||
</div>
|
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||||
</div>
|
<span>
|
||||||
|
<strong>{{ t.settings_r2_enabled }}</strong>
|
||||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
|
<small>{{ t.settings_r2_help }}</small>
|
||||||
<div class="field">
|
</span>
|
||||||
<label class="label">{{ t.settings_telegram_bot_token }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.settings_turnstile_site_key }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.settings_oidc_client_id }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.settings_oidc_client_secret }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
|
|
||||||
{{ t.settings_auth_password_enabled }}
|
|
||||||
</label>
|
</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">
|
</div>
|
||||||
<label class="checkbox">
|
<div class="field admin-field">
|
||||||
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
|
<label class="label" for="r2Bucket">{{ t.settings_r2_bucket }}</label>
|
||||||
{{ t.settings_auth_sso_enabled }}
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<header class="admin-section-head">
|
||||||
|
<span class="admin-section-icon" aria-hidden="true">🔔</span>
|
||||||
|
<div>
|
||||||
|
<div class="admin-title-row">
|
||||||
|
<h2>{{ t.settings_section_notifications }}</h2>
|
||||||
|
<span class="status-pill{% if client_notifications_checked %} is-enabled{% endif %}">
|
||||||
|
{% if client_notifications_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p>{{ t.settings_section_notifications_help }}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="admin-section-body">
|
||||||
|
<label class="admin-toggle" for="clientNotifications">
|
||||||
|
<input id="clientNotifications" type="checkbox" name="client_notifications_enabled" value="true"{% if client_notifications_checked %} checked{% endif %}>
|
||||||
|
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||||
|
<span>
|
||||||
|
<strong>{{ t.settings_client_notifications_enabled }}</strong>
|
||||||
|
<small>{{ t.settings_client_notifications_help }}</small>
|
||||||
|
</span>
|
||||||
</label>
|
</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>
|
||||||
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</button>
|
<div class="admin-subsection">
|
||||||
</form>
|
<h3>Telegram</h3>
|
||||||
</div>
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var COLORS = [
|
var colors = [
|
||||||
'rgba(124,108,255,0.18)',
|
'rgba(124,108,255,.18)', 'rgba(255,82,135,.15)',
|
||||||
'rgba(255,82,135,0.15)',
|
'rgba(255,140,38,.18)', 'rgba(0,180,150,.15)',
|
||||||
'rgba(255,140,38,0.18)',
|
'rgba(77,166,255,.18)', 'rgba(255,179,64,.18)',
|
||||||
'rgba(0,180,150,0.15)',
|
'rgba(176,108,255,.16)', 'rgba(34,180,130,.16)'
|
||||||
'rgba(77,166,255,0.18)',
|
|
||||||
'rgba(255,179,64,0.18)',
|
|
||||||
'rgba(176,108,255,0.16)',
|
|
||||||
'rgba(34,180,130,0.16)',
|
|
||||||
];
|
];
|
||||||
|
var input = document.getElementById('seoKeywordsInput');
|
||||||
var ta = document.getElementById('seoKeywordsInput');
|
|
||||||
var preview = document.getElementById('seoPreview');
|
var preview = document.getElementById('seoPreview');
|
||||||
|
|
||||||
function esc(s) {
|
function renderSeoPreview() {
|
||||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
var words = input.value.split(',').map(function(value) { return value.trim(); }).filter(Boolean);
|
||||||
}
|
preview.replaceChildren();
|
||||||
|
preview.hidden = words.length === 0;
|
||||||
function render() {
|
words.forEach(function(word, index) {
|
||||||
var text = ta.value.trim();
|
var tag = document.createElement('span');
|
||||||
if (!text) { preview.style.display = 'none'; return; }
|
tag.textContent = word;
|
||||||
|
tag.style.background = colors[index % colors.length];
|
||||||
var parts = text.split(',');
|
tag.style.borderRadius = '4px';
|
||||||
var html = '';
|
tag.style.padding = '2px 6px';
|
||||||
parts.forEach(function(part, i) {
|
tag.style.margin = '2px';
|
||||||
var word = part.trim();
|
preview.appendChild(tag);
|
||||||
if (word) {
|
|
||||||
var color = COLORS[i % COLORS.length];
|
|
||||||
html += '<span style="background:' + color + ';border-radius:4px;padding:2px 6px;margin:2px;">' + esc(word) + '</span>';
|
|
||||||
}
|
|
||||||
if (i < parts.length - 1) {
|
|
||||||
html += '<span style="color:#ccc;font-size:0.8em;margin:0 1px">,</span>';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
preview.innerHTML = html;
|
|
||||||
preview.style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ta.addEventListener('input', render);
|
input.addEventListener('input', renderSeoPreview);
|
||||||
render();
|
renderSeoPreview();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,28 +6,18 @@
|
|||||||
<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="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 { color-scheme: light; }
|
|
||||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
|
||||||
.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); }
|
|
||||||
input, textarea, select, .input, .textarea, .select select {
|
|
||||||
background-color: #fff !important; color: #333 !important; border-color: #dbdbdb !important;
|
|
||||||
}
|
|
||||||
.label, label { color: #363636 !important; }
|
|
||||||
.notification { color: #333 !important; }
|
|
||||||
</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>
|
||||||
|
|||||||
+20
-26
@@ -11,30 +11,29 @@
|
|||||||
{% 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" %}
|
{% if user.status == "active" %}
|
||||||
<form method="post" action="/admin/users/{{ user.id }}/telegram" style="margin-top:0.5rem; padding-top:0.5rem; border-top:1px solid #eee;">
|
<form method="post" action="/admin/users/{{ user.id }}/telegram" class="inline-admin-form">
|
||||||
<div class="columns is-mobile is-vcentered" style="margin-bottom:0;">
|
<div class="inline-admin-grid">
|
||||||
<div class="column">
|
<div class="field">
|
||||||
<div class="field" style="margin-bottom:0;">
|
|
||||||
<label class="label is-small">{{ t.users_telegram_chat_id }}</label>
|
<label class="label is-small">{{ t.users_telegram_chat_id }}</label>
|
||||||
<div class="control">
|
<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() }}">
|
<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>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div class="column is-narrow">
|
<label class="admin-toggle admin-toggle-compact">
|
||||||
<label class="checkbox is-small">
|
|
||||||
<input type="checkbox" name="telegram_notifications" value="true" {% if user.telegram_notifications == Some(true) %}checked{% endif %}>
|
<input type="checkbox" name="telegram_notifications" value="true" {% if user.telegram_notifications == Some(true) %}checked{% endif %}>
|
||||||
{{ t.users_telegram_enabled }}
|
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||||
|
<span><strong>{{ t.users_telegram_enabled }}</strong></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow">
|
<div>
|
||||||
<button type="submit" class="button is-small is-info is-outlined">💾</button>
|
<button type="submit" class="button is-small is-info is-outlined">💾</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,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="column">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-small">{{ t.users_login }}</label>
|
<label class="label is-small">{{ t.users_login }}</label>
|
||||||
<div class="control"><input class="input" type="text" name="login" required></div>
|
<div class="control"><input class="input" type="text" name="login" required></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-small">{{ t.users_display_name }}</label>
|
<label class="label is-small">{{ t.users_display_name }}</label>
|
||||||
<div class="control"><input class="input" type="text" name="display_name"></div>
|
<div class="control"><input class="input" type="text" name="display_name"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="columns is-mobile">
|
|
||||||
<div class="column">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-small">{{ t.users_password }}</label>
|
<label class="label is-small">{{ t.users_password }}</label>
|
||||||
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
|
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<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">
|
||||||
</div>
|
|
||||||
<button type="submit" class="button is-primary">{{ t.users_add_button }}</button>
|
<button type="submit" class="button is-primary">{{ t.users_add_button }}</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+534
-12
@@ -5,6 +5,9 @@
|
|||||||
<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="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() %}
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -16,13 +19,93 @@
|
|||||||
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;
|
||||||
@@ -51,6 +134,9 @@
|
|||||||
.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 {
|
||||||
position: relative; width: 80px; height: 60px; border-radius: 6px;
|
position: relative; width: 80px; height: 60px; border-radius: 6px;
|
||||||
overflow: hidden; background: #111;
|
overflow: hidden; background: #111;
|
||||||
@@ -110,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>
|
||||||
@@ -118,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 %}">
|
||||||
|
<div class="portal-header-backdrop" aria-hidden="true">
|
||||||
|
{% 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>
|
<h1>{{ t.portal_title }}</h1>
|
||||||
<div class="sub">{{ client.name }}</div>
|
<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">
|
||||||
@@ -129,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 }}">
|
||||||
@@ -155,13 +364,17 @@
|
|||||||
<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.unwrap() }}" data-lightbox="photo">
|
<a href="{{ m.url }}" data-lightbox="photo">
|
||||||
<img src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" 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.unwrap() }}" data-lightbox="video">
|
<a href="{{ m.url }}" data-lightbox="video">
|
||||||
<div class="vid-thumb">
|
<div class="vid-thumb media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
|
||||||
<video src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
{% 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>
|
<span class="video-play">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -200,6 +413,13 @@
|
|||||||
{% 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 -->
|
||||||
@@ -209,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 = '';
|
||||||
@@ -226,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>
|
||||||
@@ -426,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>
|
||||||
@@ -569,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>
|
||||||
|
<div class="lightbox-stage" id="lightboxStage">
|
||||||
|
<span class="lightbox-loader" aria-hidden="true"></span>
|
||||||
<img id="lightboxImg" src="" alt="">
|
<img id="lightboxImg" src="" alt="">
|
||||||
<video id="lightboxVideo" controls style="display:none;"></video>
|
<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>
|
||||||
@@ -37,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