8 Commits
Author SHA1 Message Date
ab 46351351fc Fixed client link
Build and Publish / Build and Publish Docker Image (push) Successful in 1m42s
2026-09-20 21:11:49 +03:00
ab 6449d8b59a Fixed log mobile view, plot, privacy banner
Build and Publish / Build and Publish Docker Image (push) Successful in 1m42s
2026-09-20 21:00:03 +03:00
ab 9587622285 Fixed log mobile view
Build and Publish / Build and Publish Docker Image (push) Successful in 1m44s
2026-09-20 20:30:28 +03:00
ab 866118497f added log filter
Build and Publish / Build and Publish Docker Image (push) Successful in 1m43s
2026-09-20 20:11:18 +03:00
ab 95154c56e7 Fixed video statistics
Build and Publish / Build and Publish Docker Image (push) Successful in 1m43s
2026-09-20 20:03:55 +03:00
ab 8168d33440 Fixed Media statistics
Build and Publish / Build and Publish Docker Image (push) Successful in 1m45s
2026-09-20 19:53:06 +03:00
ab 5fd26c816a Added statistics
Build and Publish / Build and Publish Docker Image (push) Successful in 3m18s
2026-09-20 17:00:59 +03:00
Ultradesu 7f49b1f314 Reworked client portal
Build and Publish / Build and Publish Docker Image (push) Successful in 3m23s
2026-09-01 10:51:22 +01:00
22 changed files with 2962 additions and 67 deletions

No files matched your search

Generated
+2 -1
View File
@@ -4763,7 +4763,7 @@ dependencies = [
[[package]] [[package]]
name = "web-petting" name = "web-petting"
version = "1.0.4" version = "1.0.7"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"aws-sdk-s3", "aws-sdk-s3",
@@ -4772,6 +4772,7 @@ dependencies = [
"chrono-tz", "chrono-tz",
"cot", "cot",
"futures", "futures",
"idna",
"image", "image",
"multer", "multer",
"password-auth", "password-auth",
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "web-petting" name = "web-petting"
version = "1.0.5" version = "1.0.7"
edition = "2024" edition = "2024"
default-run = "web-petting" default-run = "web-petting"
@@ -20,6 +20,7 @@ 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" web-push-native = "0.5"
+408
View File
@@ -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())
+346 -1
View File
@@ -1,3 +1,4 @@
use chrono::TimeZone;
use cot::Template; use cot::Template;
use cot::db::{Auto, Database, ForeignKey, Model, query}; use cot::db::{Auto, Database, ForeignKey, Model, query};
use cot::html::Html; use cot::html::Html;
@@ -321,6 +322,39 @@ struct ClientFormTemplate<'a> {
client_id: i64, client_id: i64,
client_status: &'a str, client_status: &'a str,
client_token: &'a str, client_token: &'a str,
/// Base origin for the portal link with any punycode host decoded to Unicode
/// (e.g. `https://мурняня.рф`); empty falls back to the browser origin.
portal_base: String,
}
/// Decodes any punycode (`xn--…`) labels in a URL's host to human-readable
/// Unicode, keeping the scheme and trimming a trailing slash. Returns an empty
/// string for empty input.
fn humanize_domain(raw: &str) -> String {
let raw = raw.trim();
if raw.is_empty() {
return String::new();
}
let (scheme, rest) = match raw.split_once("://") {
Some((s, r)) => (Some(s), r),
None => (None, raw),
};
let (host, path) = match rest.split_once('/') {
Some((h, p)) => (h, Some(p)),
None => (rest, None),
};
let (unicode_host, _) = idna::domain_to_unicode(host);
let mut out = String::new();
if let Some(s) = scheme {
out.push_str(s);
out.push_str("://");
}
out.push_str(&unicode_host);
if let Some(p) = path.filter(|p| !p.is_empty()) {
out.push('/');
out.push_str(p);
}
out.trim_end_matches('/').to_string()
} }
#[derive(Debug, Template)] #[derive(Debug, Template)]
@@ -352,6 +386,18 @@ struct SettingsTemplate<'a> {
vapid_private_key_configured: bool, vapid_private_key_configured: bool,
r2_secret_configured: bool, r2_secret_configured: bool,
push_subscribers: Vec<PushSubscriberItem>, push_subscribers: Vec<PushSubscriberItem>,
analytics_landing_views: u64,
analytics_landing_unique: u64,
analytics_portal_opens: u64,
analytics_media_views: u64,
}
#[derive(Debug, Template)]
#[template(path = "admin/analytics.html")]
struct AnalyticsTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
} }
fn setting_has_value(settings: &[Setting], key: &str) -> bool { fn setting_has_value(settings: &[Setting], key: &str) -> bool {
@@ -527,6 +573,47 @@ async fn admin_media_view(
}) })
} }
/// Builds a JSON reference to a media file for the analytics UI: a preview link
/// if the file still exists and is active, otherwise a minimal placeholder.
async fn media_ref_json(
storage: &crate::uploads::Storage,
clients: &[Client],
media_list: &[Media],
media_id: i64,
) -> cot::Result<serde_json::Value> {
match media_list
.iter()
.find(|m| m.id.unwrap() == media_id && m.status == "active")
{
Some(m) => {
let cid = m.client_id.primary_key().unwrap();
let client_name = clients
.iter()
.find(|c| c.id.unwrap() == cid)
.map(|c| c.name.clone())
.unwrap_or_default();
let caption = m
.caption
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let file_type = m.file_type.clone();
let view = admin_media_view(storage, m.clone()).await?;
Ok(serde_json::json!({
"media_id": media_id,
"exists": true,
"file_type": file_type,
"url": view.url,
"thumbnail_url": view.thumbnail_url,
"client_name": client_name,
"caption": caption,
}))
}
None => Ok(serde_json::json!({ "media_id": media_id, "exists": false })),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Auth Handlers // Auth Handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1216,6 +1303,7 @@ async fn client_new_page(request: Request, session: Session) -> cot::Result<Resp
client_id: 0, client_id: 0,
client_status: "", client_status: "",
client_token: "", client_token: "",
portal_base: String::new(),
} }
.render()?; .render()?;
html_response(body, lang) html_response(body, lang)
@@ -1241,6 +1329,13 @@ async fn client_edit_page(
let t = lang.t(); let t = lang.t();
let action_url = format!("/admin/clients/{}/save", client.id); let action_url = format!("/admin/clients/{}/save", client.id);
let cid = client.id.unwrap(); let cid = client.id.unwrap();
let site_domain_key = "site_domain".to_string();
let site_domain = query!(Setting, $key == site_domain_key)
.get(&db)
.await?
.map(|s| s.value)
.unwrap_or_default();
let portal_base = humanize_domain(&site_domain);
let body = ClientFormTemplate { let body = ClientFormTemplate {
t, t,
lang, lang,
@@ -1258,6 +1353,7 @@ async fn client_edit_page(
client_id: cid, client_id: cid,
client_status: &client.status, client_status: &client.status,
client_token: &client.media_token, client_token: &client.media_token,
portal_base,
} }
.render()?; .render()?;
html_response(body, lang) html_response(body, lang)
@@ -1339,6 +1435,27 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key"); let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key");
let r2_secret_configured = let r2_secret_configured =
setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY); setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY);
// Compact 30-day analytics summary shown on the settings page.
let tz = crate::tz::load_tz(&db).await;
let today = crate::tz::today_in_tz(tz);
let summary_from = today - chrono::Duration::days(29);
let summary_events = crate::analytics::load_events(&db, summary_from, today).await?;
let summary = crate::analytics::aggregate(
&summary_events,
summary_from,
today,
crate::analytics::Granularity::Day,
);
let metric_total = |key: &str| {
summary
.metrics
.iter()
.find(|m| m.key == key)
.map(|m| m.total)
.unwrap_or(0)
};
let body = SettingsTemplate { let body = SettingsTemplate {
t: lang.t(), t: lang.t(),
lang, lang,
@@ -1356,11 +1473,192 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
vapid_private_key_configured, vapid_private_key_configured,
r2_secret_configured, r2_secret_configured,
push_subscribers: load_push_subscribers(&db).await?, push_subscribers: load_push_subscribers(&db).await?,
analytics_landing_views: metric_total("landing_views"),
analytics_landing_unique: metric_total("landing_unique"),
analytics_portal_opens: metric_total("portal_opens"),
analytics_media_views: metric_total("media_views"),
} }
.render()?; .render()?;
html_response(body, lang) html_response(body, lang)
} }
async fn analytics_page(request: Request, session: Session) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let body = AnalyticsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
}
.render()?;
html_response(body, lang)
}
/// JSON time-series + rankings for the analytics dashboard.
/// Query params: `from`, `to` (YYYY-MM-DD), `granularity` (day|week|month).
async fn analytics_data(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
if require_auth(&session, lang).await.is_err() {
return Html::new("{}")
.with_header("content-type", "application/json")
.into_response();
}
let query_str = request.uri().query().unwrap_or("");
let mut from_str = "";
let mut to_str = "";
let mut gran_str = "day";
for pair in query_str.split('&') {
if let Some(v) = pair.strip_prefix("from=") {
from_str = v;
} else if let Some(v) = pair.strip_prefix("to=") {
to_str = v;
} else if let Some(v) = pair.strip_prefix("granularity=") {
gran_str = v;
}
}
let tz = crate::tz::load_tz(&db).await;
let tz_today = crate::tz::today_in_tz(tz);
let to = chrono::NaiveDate::parse_from_str(to_str, "%Y-%m-%d").unwrap_or(tz_today);
let from = chrono::NaiveDate::parse_from_str(from_str, "%Y-%m-%d")
.unwrap_or_else(|_| to - chrono::Duration::days(29));
// Guard against an inverted range.
let (from, to) = if from <= to { (from, to) } else { (to, from) };
let granularity = crate::analytics::Granularity::from_code(gran_str);
let events = crate::analytics::load_events(&db, from, to).await?;
let mut report = crate::analytics::aggregate(&events, from, to, granularity);
let clients = Client::objects().all(&db).await?;
let media = Media::objects().all(&db).await?;
let client_name = |id: i64| {
clients
.iter()
.find(|c| c.id.unwrap() == id)
.map(|c| c.name.clone())
.unwrap_or_else(|| format!("#{id}"))
};
report.top_clients = crate::analytics::top_entries(
crate::analytics::portal_opens_by_client(&events),
10,
client_name,
);
// Top media, enriched with preview links so they can be opened in-page.
let storage = crate::uploads::Storage::load(&db).await?;
let mut media_counts: Vec<(i64, u64)> = crate::analytics::media_views_by_id(&events)
.into_iter()
.collect();
media_counts.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
media_counts.truncate(10);
let mut top_media = Vec::with_capacity(media_counts.len());
for (media_id, count) in media_counts {
let mut entry = media_ref_json(&storage, &clients, &media, media_id).await?;
entry["count"] = serde_json::json!(count);
top_media.push(entry);
}
let json = serde_json::json!({
"labels": report.labels,
"metrics": report.metrics,
"top_clients": report.top_clients,
"top_media": top_media,
});
Html::new(json.to_string())
.with_header("content-type", "application/json")
.into_response()
}
/// JSON page of recent events for the activity log, newest first.
/// Query params: `before` (id cursor for older pages), `limit`.
async fn analytics_events(
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if require_auth(&session, lang).await.is_err() {
return Html::new("{}")
.with_header("content-type", "application/json")
.into_response();
}
let query_str = request.uri().query().unwrap_or("");
let mut before: Option<i64> = None;
let mut limit: usize = 40;
let mut types: Vec<String> = Vec::new();
let mut types_present = false;
for pair in query_str.split('&') {
if let Some(v) = pair.strip_prefix("before=") {
before = v.parse().ok();
} else if let Some(v) = pair.strip_prefix("limit=")
&& let Ok(n) = v.parse::<usize>()
{
limit = n.clamp(1, 100);
} else if let Some(v) = pair.strip_prefix("types=") {
types_present = true;
if !v.is_empty() {
types.push(v.to_string());
}
}
}
let allowed = if types_present {
Some(types.as_slice())
} else {
None
};
let page = crate::analytics::recent_events(&db, before, limit, allowed).await?;
let next_cursor = if page.len() == limit {
page.last().map(|e| e.id.unwrap())
} else {
None
};
let tz = crate::tz::load_tz(&db).await;
let clients = Client::objects().all(&db).await?;
let media = Media::objects().all(&db).await?;
let storage = crate::uploads::Storage::load(&db).await?;
let mut out = Vec::with_capacity(page.len());
for e in &page {
let client_id = e.client_id.as_ref().map(|fk| fk.primary_key().unwrap());
let client_name = client_id.map(|cid| {
clients
.iter()
.find(|c| c.id.unwrap() == cid)
.map(|c| c.name.clone())
.unwrap_or_else(|| format!("#{cid}"))
});
let media_ref = match e.media_id {
Some(mid) => Some(media_ref_json(&storage, &clients, &media, mid).await?),
None => None,
};
let local = tz.from_utc_datetime(&e.created_at);
out.push(serde_json::json!({
"id": e.id.unwrap(),
"created_at": local.format("%Y-%m-%d %H:%M").to_string(),
"event_type": e.event_type,
"client_id": client_id,
"client_name": client_name,
"visitor": e.visitor_hash.chars().take(8).collect::<String>(),
"media": media_ref,
"user_agent": e.user_agent,
"referer": e.referer,
}));
}
let json = serde_json::json!({ "events": out, "next_cursor": next_cursor });
Html::new(json.to_string())
.with_header("content-type", "application/json")
.into_response()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST Handlers (protected) // POST Handlers (protected)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1636,6 +1934,27 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key"); let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key");
let r2_secret_configured = let r2_secret_configured =
setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY); setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY);
// Compact 30-day analytics summary shown on the settings page.
let tz = crate::tz::load_tz(&db).await;
let today = crate::tz::today_in_tz(tz);
let summary_from = today - chrono::Duration::days(29);
let summary_events = crate::analytics::load_events(&db, summary_from, today).await?;
let summary = crate::analytics::aggregate(
&summary_events,
summary_from,
today,
crate::analytics::Granularity::Day,
);
let metric_total = |key: &str| {
summary
.metrics
.iter()
.find(|m| m.key == key)
.map(|m| m.total)
.unwrap_or(0)
};
let rendered = SettingsTemplate { let rendered = SettingsTemplate {
t: lang.t(), t: lang.t(),
lang, lang,
@@ -1653,6 +1972,10 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
vapid_private_key_configured, vapid_private_key_configured,
r2_secret_configured, r2_secret_configured,
push_subscribers: load_push_subscribers(&db).await?, push_subscribers: load_push_subscribers(&db).await?,
analytics_landing_views: metric_total("landing_views"),
analytics_landing_unique: metric_total("landing_unique"),
analytics_portal_opens: metric_total("portal_opens"),
analytics_media_views: metric_total("media_views"),
} }
.render()?; .render()?;
html_response(rendered, lang) html_response(rendered, lang)
@@ -3251,12 +3574,19 @@ pub fn admin_router() -> Router {
), ),
Route::with_handler_and_name("/settings", settings_page, "admin-settings-get"), Route::with_handler_and_name("/settings", settings_page, "admin-settings-get"),
Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"), Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"),
Route::with_handler_and_name("/analytics", analytics_page, "admin-analytics"),
Route::with_handler_and_name("/analytics/data", analytics_data, "admin-analytics-data"),
Route::with_handler_and_name(
"/analytics/events",
analytics_events,
"admin-analytics-events",
),
]) ])
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::preserved_secret_value; use super::{humanize_domain, preserved_secret_value};
#[test] #[test]
fn keeps_masks_and_updates_secrets_consistently() { fn keeps_masks_and_updates_secrets_consistently() {
@@ -3265,4 +3595,19 @@ mod tests {
assert_eq!(preserved_secret_value("stored", "", true), ""); assert_eq!(preserved_secret_value("stored", "", true), "");
assert_eq!(preserved_secret_value("stored", "new", true), "new"); assert_eq!(preserved_secret_value("stored", "new", true), "new");
} }
#[test]
fn decodes_punycode_host_to_unicode() {
assert_eq!(
humanize_domain("https://xn--l1acako8eb.xn--p1ai"),
"https://мурняня.рф"
);
assert_eq!(
humanize_domain("https://xn--l1acako8eb.xn--p1ai/"),
"https://мурняня.рф"
);
// Plain ASCII domains are returned unchanged (minus trailing slash).
assert_eq!(humanize_domain("https://example.com/"), "https://example.com");
assert_eq!(humanize_domain(""), "");
}
} }
+528
View File
@@ -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");
}
}
+140
View File
@@ -187,6 +187,47 @@ pub struct Translations {
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,
@@ -244,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,
@@ -330,6 +377,7 @@ 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: &'static str,
pub portal_notifications_text: &'static str, pub portal_notifications_text: &'static str,
pub portal_notifications_enable: &'static str, pub portal_notifications_enable: &'static str,
@@ -472,6 +520,46 @@ static RU: Translations = Translations {
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: "На сегодня визитов нет.",
@@ -504,6 +592,7 @@ 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: "Уведомления",
portal_notifications_text: "Получайте уведомления о завершённых визитах, даже когда страница закрыта. На iPhone сначала добавьте сайт на экран «Домой» и откройте его оттуда.", portal_notifications_text: "Получайте уведомления о завершённых визитах, даже когда страница закрыта. На iPhone сначала добавьте сайт на экран «Домой» и откройте его оттуда.",
portal_notifications_enable: "Включить уведомления", portal_notifications_enable: "Включить уведомления",
@@ -605,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: "Отзывов пока нет.",
@@ -747,6 +841,46 @@ static EN: Translations = Translations {
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.",
@@ -779,6 +913,7 @@ 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: "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_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_enable: "Enable notifications",
@@ -880,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.",
+1
View File
@@ -1,4 +1,5 @@
mod admin; mod admin;
mod analytics;
mod i18n; mod i18n;
mod migrations; mod migrations;
pub mod models; pub mod models;
+4
View File
@@ -4,8 +4,12 @@
pub mod m_0001_initial; pub mod m_0001_initial;
pub mod m_0002_push_subscription; 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] = &[ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration, &m_0001_initial::Migration,
&m_0002_push_subscription::Migration, &m_0002_push_subscription::Migration,
&m_0003_analytics_event::Migration,
&m_0004_analytics_event_meta::Migration,
]; ];
+61
View File
@@ -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(),
];
}
+46
View File
@@ -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]
+141 -20
View File
@@ -14,7 +14,9 @@ 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, PushSubscription, 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 {
@@ -103,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);
@@ -132,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)
@@ -193,7 +224,16 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
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)]
@@ -274,13 +314,10 @@ async fn portal_media_view(
) -> cot::Result<PortalMediaView> { ) -> cot::Result<PortalMediaView> {
let media_id = media.id.unwrap(); let media_id = media.id.unwrap();
let delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path); let delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
let url = storage // Always route full-size opens through our handler so the view is counted
.public_url( // even with R2 storage (the handler records the event, then redirects to the
&delivery.media_path, // signed URL). Thumbnails are still served directly and are not counted.
format!("/client/{client_token}/media/{media_id}"), let url = format!("/client/{client_token}/media/{media_id}");
)
.await
.map_err(crate::uploads::storage_error)?;
let thumbnail_fallback = format!("/client/{client_token}/media/{media_id}/thumbnail"); let thumbnail_fallback = format!("/client/{client_token}/media/{media_id}/thumbnail");
let thumbnail_url = if storage.is_r2() let thumbnail_url = if storage.is_r2()
&& storage && storage
@@ -323,11 +360,13 @@ 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, notifications_enabled: bool,
show_portal_settings: bool,
vapid_public_key: String, vapid_public_key: String,
calendar_months: Vec<CalendarMonth>, calendar_months: Vec<CalendarMonth>,
page: usize, page: usize,
@@ -337,6 +376,7 @@ struct ClientPortalTemplate<'a> {
} }
const PORTAL_VISITS_PER_PAGE: usize = 10; const PORTAL_VISITS_PER_PAGE: usize = 10;
const PORTAL_HEADER_MEDIA_LIMIT: usize = 20;
fn query_page(request: &Request) -> usize { fn query_page(request: &Request) -> usize {
request request
@@ -405,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);
@@ -444,24 +497,53 @@ async fn client_portal(
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 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 media_by_visit: HashMap<i64, Vec<PortalMediaView>> = HashMap::new();
for media in all_media { let mut header_media = Vec::with_capacity(client_media.len().min(PORTAL_HEADER_MEDIA_LIMIT));
if media.status != "active" || media.client_id.primary_key().unwrap() != client_id { for media in client_media {
continue; let visit_id = media
}
let Some(visit_id) = media
.visit_id .visit_id
.as_ref() .as_ref()
.map(|foreign_key| foreign_key.primary_key().unwrap()) .map(|foreign_key| foreign_key.primary_key().unwrap())
else { .expect("client portal media was filtered to visit media");
continue; let include_in_header = header_media.len() < PORTAL_HEADER_MEDIA_LIMIT;
}; let include_in_visit = visible_visit_ids.contains(&visit_id);
if !visible_visit_ids.contains(&visit_id) { if !include_in_header && !include_in_visit {
continue; continue;
} }
let media_view = portal_media_view(&storage, media, &client.media_token).await?; 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); 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();
@@ -498,7 +580,7 @@ async fn client_portal(
month_keys.sort(); month_keys.sort();
month_keys.dedup(); month_keys.dedup();
month_keys.reverse(); month_keys.reverse();
let calendar_months = month_keys let calendar_months: Vec<CalendarMonth> = month_keys
.into_iter() .into_iter()
.map(|(year, month)| { .map(|(year, month)| {
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap(); let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
@@ -570,15 +652,18 @@ async fn client_portal(
.unwrap_or(false); .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, notifications_enabled,
show_portal_settings,
vapid_public_key, vapid_public_key,
calendar_months, calendar_months,
page, page,
@@ -587,7 +672,16 @@ async fn client_portal(
has_next_page: page < total_pages, 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)] #[derive(Deserialize)]
@@ -859,6 +953,31 @@ 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);
// Analytics: count full-size media opens (photos and videos alike). A video
// streams via many HTTP range requests, so we dedupe by visitor+media within
// a short window to count a single open instead of every chunk/seek.
let visitor_id = crate::analytics::visitor_id_from_request(&request)
.unwrap_or_else(crate::analytics::new_visitor_id);
let recently_viewed = crate::analytics::media_viewed_recently(
&db,
media_id,
&visitor_id,
chrono::Duration::minutes(30),
)
.await
.unwrap_or(false);
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 storage = crate::uploads::Storage::load(&db).await?;
let display_path = let display_path =
crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path; crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path;
@@ -1047,6 +1166,7 @@ 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", "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",
@@ -1128,6 +1248,7 @@ pub fn public_router() -> Router {
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}",
+20
View File
@@ -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
+668
View File
@@ -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 %}
+2 -1
View File
@@ -72,7 +72,8 @@
<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' });
})(); })();
+4
View File
@@ -21,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">
@@ -58,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>
+38
View File
@@ -18,6 +18,44 @@
<div class="notification is-danger is-light admin-message">{{ message }}</div> <div class="notification is-danger is-light admin-message">{{ message }}</div>
{% endif %} {% endif %}
<section class="admin-section" style="margin-bottom:1rem;">
<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>
<style>
.settings-analytics-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:14px; }
.settings-analytics-kpi { border-left:4px solid var(--kpi-color,#7c6ed4); padding:8px 14px; background:rgba(0,0,0,.02); border-radius:8px; display:flex; flex-direction:column; }
.settings-analytics-kpi .kpi-value { font-size:1.6rem; font-weight:700; line-height:1.1; }
.settings-analytics-kpi .kpi-label { font-size:.78rem; color:#777; margin-top:2px; }
</style>
<form id="settingsForm" class="admin-form" method="post" action="/admin/settings/save"> <form id="settingsForm" class="admin-form" method="post" action="/admin/settings/save">
<section class="admin-section"> <section class="admin-section">
<header class="admin-section-head"> <header class="admin-section-head">
+359 -43
View File
@@ -19,16 +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 {
position: relative; position: relative; isolation: isolate; overflow: visible; min-height: 150px;
background: linear-gradient(135deg, #7c6cff, #b06cff); display: grid; place-items: center; padding: 2rem 1.5rem 1.5rem;
color: #fff; padding: 2rem 1.5rem 1.5rem; text-align: center; 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; }
.portal-header.has-media .sub { opacity: .95; }
.container { max-width: 1100px; margin: 0 auto; padding: 0 1rem; } .container { max-width: 1100px; margin: 0 auto; padding: 0 1rem; }
.portal-grid { display: grid; grid-template-columns: minmax(0, 700px) 320px; gap: 1.25rem; align-items: start; } .portal-grid { display: grid; grid-template-columns: minmax(0, 700px) 320px; gap: 1.25rem; align-items: start; }
.portal-settings { position: absolute; right: 1rem; bottom: 1rem; width: 38px; height: 38px; border: 0; border-radius: 50%; background: rgba(255,255,255,.2); color: #fff; font-size: 1.1rem; cursor: pointer; } .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;
@@ -119,9 +196,11 @@
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; background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; } .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 { 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-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-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, .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-weekdays { color: #999; font-size: .68rem; margin-bottom: 3px; }
@@ -131,20 +210,87 @@
.calendar-day.future { background: #f3f3f3; color: #bbb; border: 1px dashed #ddd; } .calendar-day.future { background: #f3f3f3; color: #bbb; border: 1px dashed #ddd; }
.calendar-month[hidden] { display: none; } .calendar-month[hidden] { display: none; }
.calendar-legend { margin-top: .65rem; font-size: .72rem; color: #999; } .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 { 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; } .pagination a { color: #6558c8; text-decoration: none; padding: .35rem .7rem; background: #fff; border: 1px solid #e5e1ff; border-radius: 8px; }
.modal-bg { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(20,18,40,.55); align-items: center; justify-content: center; padding: 1rem; }
.modal-bg.open { display: flex; }
.notification-modal { width: min(420px, 100%); background: #fff; border-radius: 14px; padding: 1.2rem; box-shadow: 0 15px 50px rgba(0,0,0,.25); }
.notification-modal h2 { font-size: 1.15rem; margin-bottom: .45rem; }
.notification-modal p { font-size: .88rem; color: #777; }
.notification-actions { display: flex; gap: .5rem; margin-top: 1rem; }
.notification-actions button { border: 0; border-radius: 8px; padding: .55rem .9rem; cursor: pointer; }
.notification-primary { background: #7567e8; color: #fff; }
@media (max-width: 800px) { @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; } .portal-grid { display: flex; flex-direction: column; }
.calendar-panel { position: static; order: -1; width: 100%; margin-top: 1rem; }
.visits-column { width: 100%; } .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>
@@ -154,10 +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>
{% if notifications_enabled %}<button class="portal-settings" type="button" onclick="openNotificationSettings()" aria-label="{{ t.portal_notifications }}">⚙</button>{% endif %} </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">
@@ -266,12 +436,15 @@
{% endif %} {% endif %}
</main> </main>
{% if !calendar_months.is_empty() %} {% if show_portal_settings %}
<aside class="calendar-panel"> <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"> <div class="calendar-head">
<button type="button" onclick="moveCalendar(1)" aria-label="{{ t.portal_previous }}">‹</button> <button type="button" onclick="moveCalendar(1)" aria-label="{{ t.portal_previous }}">‹</button>
<span class="calendar-title" id="calendarTitle">{{ t.portal_calendar }}</span> <span class="calendar-title" id="calendarTitle">{{ t.portal_calendar }}</span>
<button type="button" onclick="moveCalendar(-1)" aria-label="{{ t.portal_next }}">›</button> <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>
<div class="calendar-weekdays"><span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span></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 %} {% for month in &calendar_months %}
@@ -289,27 +462,171 @@
</div> </div>
{% endfor %} {% endfor %}
<div class="calendar-legend">{{ t.portal_future_visit }} — ···</div> <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> </aside>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if notifications_enabled %}
<div class="modal-bg" id="notificationModal" onclick="if(event.target===this) closeNotificationSettings()">
<div class="notification-modal">
<h2>{{ t.portal_notifications }}</h2>
<p id="notificationText">{{ t.portal_notifications_text }}</p>
<p id="notificationStatus" style="display:none;margin-top:0.65rem;font-weight:600;"></p>
<div class="notification-actions">
<button type="button" class="notification-primary" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
<button type="button" onclick="closeNotificationSettings()">✕</button>
</div>
</div>
</div>
{% endif %}
<script> <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 = '';
@@ -349,7 +666,7 @@ renderCalendar();
} }
function showStatus(message, error) { function showStatus(message, error) {
status.textContent = message; status.textContent = message;
status.style.display = ''; status.style.display = 'block';
status.style.color = error ? '#b42318' : '#067647'; status.style.color = error ? '#b42318' : '#067647';
} }
async function saveSubscription(value) { async function saveSubscription(value) {
@@ -386,14 +703,6 @@ renderCalendar();
} }
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}'; toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
} }
window.openNotificationSettings = function() {
document.getElementById('notificationModal').classList.add('open');
refresh().catch(function(error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
});
};
window.closeNotificationSettings = function() { document.getElementById('notificationModal').classList.remove('open'); };
toggle.addEventListener('click', async function() { toggle.addEventListener('click', async function() {
toggle.disabled = true; toggle.disabled = true;
try { try {
@@ -405,10 +714,11 @@ renderCalendar();
}); });
await subscription.unsubscribe(); await subscription.unsubscribe();
subscription = null; subscription = null;
status.style.display = 'none';
} else { } else {
var permission = await Notification.requestPermission(); var permission = await Notification.requestPermission();
if (permission !== 'granted') { if (permission !== 'granted') {
document.getElementById('notificationText').textContent = '{{ t.portal_notifications_denied }}'; showStatus('{{ t.portal_notifications_denied }}', true);
return; return;
} }
subscription = await registration.pushManager.subscribe({ subscription = await registration.pushManager.subscribe({
@@ -432,7 +742,13 @@ renderCalendar();
}); });
})(); })();
{% endif %} {% 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>
+1
View File
@@ -569,5 +569,6 @@
})(); })();
</script> </script>
{% include "partials/cookie_banner.html" %}
</body> </body>
</html> </html>
+47
View File
@@ -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>
+97
View File
@@ -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>
+1
View File
@@ -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>