Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46351351fc | ||
|
|
6449d8b59a | ||
|
|
9587622285 | ||
|
|
866118497f | ||
|
|
95154c56e7 | ||
|
|
8168d33440 | ||
|
|
5fd26c816a | ||
|
|
7f49b1f314 | ||
|
|
79c287eaa0 |
No files matched your search
Generated
+2
-1
@@ -4763,7 +4763,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-petting"
|
||||
version = "1.0.4"
|
||||
version = "1.0.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-sdk-s3",
|
||||
@@ -4772,6 +4772,7 @@ dependencies = [
|
||||
"chrono-tz",
|
||||
"cot",
|
||||
"futures",
|
||||
"idna",
|
||||
"image",
|
||||
"multer",
|
||||
"password-auth",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "1.0.4"
|
||||
version = "1.0.7"
|
||||
edition = "2024"
|
||||
default-run = "web-petting"
|
||||
|
||||
@@ -20,6 +20,7 @@ tokio = { version = "1", features = ["fs", "process", "rt-multi-thread"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
idna = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
web-push-native = "0.5"
|
||||
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
use chrono::TimeZone;
|
||||
use cot::Template;
|
||||
use cot::db::{Auto, Database, ForeignKey, Model, query};
|
||||
use cot::html::Html;
|
||||
@@ -321,6 +322,39 @@ struct ClientFormTemplate<'a> {
|
||||
client_id: i64,
|
||||
client_status: &'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)]
|
||||
@@ -352,6 +386,18 @@ struct SettingsTemplate<'a> {
|
||||
vapid_private_key_configured: bool,
|
||||
r2_secret_configured: bool,
|
||||
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 {
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1216,6 +1303,7 @@ async fn client_new_page(request: Request, session: Session) -> cot::Result<Resp
|
||||
client_id: 0,
|
||||
client_status: "",
|
||||
client_token: "",
|
||||
portal_base: String::new(),
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
@@ -1241,6 +1329,13 @@ async fn client_edit_page(
|
||||
let t = lang.t();
|
||||
let action_url = format!("/admin/clients/{}/save", client.id);
|
||||
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 {
|
||||
t,
|
||||
lang,
|
||||
@@ -1258,6 +1353,7 @@ async fn client_edit_page(
|
||||
client_id: cid,
|
||||
client_status: &client.status,
|
||||
client_token: &client.media_token,
|
||||
portal_base,
|
||||
}
|
||||
.render()?;
|
||||
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 r2_secret_configured =
|
||||
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 {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
@@ -1356,11 +1473,192 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
|
||||
vapid_private_key_configured,
|
||||
r2_secret_configured,
|
||||
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()?;
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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 r2_secret_configured =
|
||||
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 {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
@@ -1653,6 +1972,10 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
vapid_private_key_configured,
|
||||
r2_secret_configured,
|
||||
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()?;
|
||||
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/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)]
|
||||
mod tests {
|
||||
use super::preserved_secret_value;
|
||||
use super::{humanize_domain, preserved_secret_value};
|
||||
|
||||
#[test]
|
||||
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", "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(""), "");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -187,6 +187,47 @@ pub struct Translations {
|
||||
pub landing_contact_label: &'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
|
||||
pub dashboard_title: &'static str,
|
||||
pub dashboard_today_visits: &'static str,
|
||||
@@ -244,6 +285,12 @@ pub struct Translations {
|
||||
pub landing_footer_text: &'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
|
||||
pub nav_testimonials: &'static str,
|
||||
pub testimonials_title: &'static str,
|
||||
@@ -330,6 +377,7 @@ pub struct Translations {
|
||||
pub portal_feedback_submit: &'static str,
|
||||
pub portal_feedback_thanks: &'static str,
|
||||
pub portal_link: &'static str,
|
||||
pub portal_settings: &'static str,
|
||||
pub portal_notifications: &'static str,
|
||||
pub portal_notifications_text: &'static str,
|
||||
pub portal_notifications_enable: &'static str,
|
||||
@@ -472,6 +520,46 @@ static RU: Translations = Translations {
|
||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||
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_today_visits: "Визиты на сегодня",
|
||||
dashboard_no_visits: "На сегодня визитов нет.",
|
||||
@@ -504,6 +592,7 @@ static RU: Translations = Translations {
|
||||
portal_feedback_submit: "Отправить",
|
||||
portal_feedback_thanks: "Спасибо за отзыв!",
|
||||
portal_link: "Ссылка клиента",
|
||||
portal_settings: "Настройки",
|
||||
portal_notifications: "Уведомления",
|
||||
portal_notifications_text: "Получайте уведомления о завершённых визитах, даже когда страница закрыта. На iPhone сначала добавьте сайт на экран «Домой» и откройте его оттуда.",
|
||||
portal_notifications_enable: "Включить уведомления",
|
||||
@@ -605,6 +694,11 @@ static RU: Translations = Translations {
|
||||
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
|
||||
landing_footer_copyright: "Все права защищены",
|
||||
|
||||
cookie_banner_text: "Мы используем файлы cookie для работы сайта и анонимной статистики посещений.",
|
||||
cookie_banner_accept: "Принять",
|
||||
cookie_privacy_link: "Политика конфиденциальности",
|
||||
privacy_title: "Политика конфиденциальности и использования cookie",
|
||||
|
||||
nav_testimonials: "Отзывы",
|
||||
testimonials_title: "Отзывы",
|
||||
testimonials_empty: "Отзывов пока нет.",
|
||||
@@ -747,6 +841,46 @@ static EN: Translations = Translations {
|
||||
landing_contact_label: "Or contact us directly",
|
||||
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_today_visits: "Today's visits",
|
||||
dashboard_no_visits: "No visits for today.",
|
||||
@@ -779,6 +913,7 @@ static EN: Translations = Translations {
|
||||
portal_feedback_submit: "Submit",
|
||||
portal_feedback_thanks: "Thank you for your feedback!",
|
||||
portal_link: "Client link",
|
||||
portal_settings: "Settings",
|
||||
portal_notifications: "Notifications",
|
||||
portal_notifications_text: "Receive completed-visit notifications even when this page is closed. On iPhone, first add this site to the Home Screen and open it from there.",
|
||||
portal_notifications_enable: "Enable notifications",
|
||||
@@ -880,6 +1015,11 @@ static EN: Translations = Translations {
|
||||
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
|
||||
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",
|
||||
testimonials_title: "Testimonials",
|
||||
testimonials_empty: "No testimonials yet.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod admin;
|
||||
mod analytics;
|
||||
mod i18n;
|
||||
mod migrations;
|
||||
pub mod models;
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
|
||||
pub mod m_0001_initial;
|
||||
pub mod m_0002_push_subscription;
|
||||
pub mod m_0003_analytics_event;
|
||||
pub mod m_0004_analytics_event_meta;
|
||||
/// The list of migrations for current app.
|
||||
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
||||
&m_0001_initial::Migration,
|
||||
&m_0002_push_subscription::Migration,
|
||||
&m_0003_analytics_event::Migration,
|
||||
&m_0004_analytics_event_meta::Migration,
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Append-only analytics events (landing visits, portal opens, media views).
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0003_analytics_event";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0001_initial",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
|
||||
.fields(&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("event_type"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<cot::db::ForeignKey<crate::models::Client>> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("media_id"),
|
||||
<Option<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<i64> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visitor_hash"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Add request metadata columns (user agent, referer, path) to analytics events.
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(super) struct Migration;
|
||||
|
||||
impl ::cot::db::migrations::Migration for Migration {
|
||||
const APP_NAME: &'static str = "web-petting";
|
||||
const MIGRATION_NAME: &'static str = "m_0004_analytics_event_meta";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0003_analytics_event",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("user_agent"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("referer"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__analytics_event"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("path"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
@@ -206,6 +206,52 @@ pub struct Testimonial {
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
/// 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.).
|
||||
#[derive(Debug, Clone)]
|
||||
#[model]
|
||||
|
||||
+143
-21
@@ -14,7 +14,9 @@ use tracing::info;
|
||||
use cot::db::query;
|
||||
|
||||
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;
|
||||
|
||||
fn detect_lang(request: &Request) -> Lang {
|
||||
@@ -103,6 +105,19 @@ struct ThankYouTemplate<'a> {
|
||||
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> {
|
||||
let lang = detect_lang(&request);
|
||||
|
||||
@@ -132,6 +147,22 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||
ua = ua,
|
||||
"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 contact_info = query!(Setting, $key == key)
|
||||
.get(&db)
|
||||
@@ -193,7 +224,16 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||
turnstile_site_key,
|
||||
}
|
||||
.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)]
|
||||
@@ -274,13 +314,10 @@ async fn portal_media_view(
|
||||
) -> cot::Result<PortalMediaView> {
|
||||
let media_id = media.id.unwrap();
|
||||
let delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
|
||||
let url = storage
|
||||
.public_url(
|
||||
&delivery.media_path,
|
||||
format!("/client/{client_token}/media/{media_id}"),
|
||||
)
|
||||
.await
|
||||
.map_err(crate::uploads::storage_error)?;
|
||||
// Always route full-size opens through our handler so the view is counted
|
||||
// even with R2 storage (the handler records the event, then redirects to the
|
||||
// signed URL). Thumbnails are still served directly and are not counted.
|
||||
let url = format!("/client/{client_token}/media/{media_id}");
|
||||
let thumbnail_fallback = format!("/client/{client_token}/media/{media_id}/thumbnail");
|
||||
let thumbnail_url = if storage.is_r2()
|
||||
&& storage
|
||||
@@ -323,11 +360,13 @@ struct ClientPortalTemplate<'a> {
|
||||
t: &'a Translations,
|
||||
lang: Lang,
|
||||
client: Client,
|
||||
header_media: Vec<PortalMediaView>,
|
||||
upcoming: Vec<PortalVisit>,
|
||||
past: Vec<PortalVisit>,
|
||||
feedback_sent: bool,
|
||||
turnstile_site_key: String,
|
||||
notifications_enabled: bool,
|
||||
show_portal_settings: bool,
|
||||
vapid_public_key: String,
|
||||
calendar_months: Vec<CalendarMonth>,
|
||||
page: usize,
|
||||
@@ -337,6 +376,7 @@ struct ClientPortalTemplate<'a> {
|
||||
}
|
||||
|
||||
const PORTAL_VISITS_PER_PAGE: usize = 10;
|
||||
const PORTAL_HEADER_MEDIA_LIMIT: usize = 20;
|
||||
|
||||
fn query_page(request: &Request) -> usize {
|
||||
request
|
||||
@@ -405,6 +445,19 @@ async fn client_portal(
|
||||
};
|
||||
|
||||
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 today = crate::tz::today_in_tz(tz);
|
||||
|
||||
@@ -444,23 +497,52 @@ async fn client_portal(
|
||||
let users = User::objects().all(&db).await?;
|
||||
let all_media = Media::objects().all(&db).await?;
|
||||
let storage = crate::uploads::Storage::load(&db).await?;
|
||||
let portal_visit_ids: HashSet<i64> = past_visits
|
||||
.iter()
|
||||
.chain(upcoming_visits.iter())
|
||||
.map(|visit| visit.id.unwrap())
|
||||
.collect();
|
||||
let mut client_media: Vec<_> = all_media
|
||||
.into_iter()
|
||||
.filter(|media| {
|
||||
media.status == "active"
|
||||
&& media.client_id.primary_key().unwrap() == client_id
|
||||
&& media
|
||||
.visit_id
|
||||
.as_ref()
|
||||
.map(|foreign_key| {
|
||||
portal_visit_ids.contains(&foreign_key.primary_key().unwrap())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
client_media.sort_by(|left, right| {
|
||||
right
|
||||
.created_at
|
||||
.cmp(&left.created_at)
|
||||
.then_with(|| right.id.unwrap().cmp(&left.id.unwrap()))
|
||||
});
|
||||
|
||||
let mut media_by_visit: HashMap<i64, Vec<PortalMediaView>> = HashMap::new();
|
||||
for media in all_media {
|
||||
if media.status != "active" || media.client_id.primary_key().unwrap() != client_id {
|
||||
continue;
|
||||
}
|
||||
let Some(visit_id) = media
|
||||
let mut header_media = Vec::with_capacity(client_media.len().min(PORTAL_HEADER_MEDIA_LIMIT));
|
||||
for media in client_media {
|
||||
let visit_id = media
|
||||
.visit_id
|
||||
.as_ref()
|
||||
.map(|foreign_key| foreign_key.primary_key().unwrap())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !visible_visit_ids.contains(&visit_id) {
|
||||
.expect("client portal media was filtered to visit media");
|
||||
let include_in_header = header_media.len() < PORTAL_HEADER_MEDIA_LIMIT;
|
||||
let include_in_visit = visible_visit_ids.contains(&visit_id);
|
||||
if !include_in_header && !include_in_visit {
|
||||
continue;
|
||||
}
|
||||
let media_view = portal_media_view(&storage, media, &client.media_token).await?;
|
||||
media_by_visit.entry(visit_id).or_default().push(media_view);
|
||||
if include_in_header {
|
||||
header_media.push(media_view.clone());
|
||||
}
|
||||
if include_in_visit {
|
||||
media_by_visit.entry(visit_id).or_default().push(media_view);
|
||||
}
|
||||
}
|
||||
|
||||
let build_portal_visit = |v: Visit| -> PortalVisit {
|
||||
@@ -498,7 +580,7 @@ async fn client_portal(
|
||||
month_keys.sort();
|
||||
month_keys.dedup();
|
||||
month_keys.reverse();
|
||||
let calendar_months = month_keys
|
||||
let calendar_months: Vec<CalendarMonth> = month_keys
|
||||
.into_iter()
|
||||
.map(|(year, month)| {
|
||||
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
||||
@@ -570,15 +652,18 @@ async fn client_portal(
|
||||
.unwrap_or(false);
|
||||
|
||||
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||
let show_portal_settings = notifications_enabled || !calendar_months.is_empty();
|
||||
let body = ClientPortalTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
client,
|
||||
header_media,
|
||||
upcoming,
|
||||
past,
|
||||
feedback_sent,
|
||||
turnstile_site_key,
|
||||
notifications_enabled,
|
||||
show_portal_settings,
|
||||
vapid_public_key,
|
||||
calendar_months,
|
||||
page,
|
||||
@@ -587,7 +672,16 @@ async fn client_portal(
|
||||
has_next_page: page < total_pages,
|
||||
}
|
||||
.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)]
|
||||
@@ -859,6 +953,31 @@ async fn portal_media(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.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 display_path =
|
||||
crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path;
|
||||
@@ -1046,6 +1165,8 @@ async fn serve_static(_request: Request, Path(filename): Path<String>) -> cot::R
|
||||
},
|
||||
};
|
||||
let content_type = match ext {
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"js" => "text/javascript; charset=utf-8",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
@@ -1127,6 +1248,7 @@ pub fn public_router() -> Router {
|
||||
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
|
||||
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
|
||||
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
|
||||
Route::with_handler_and_name("/privacy", privacy_page, "privacy"),
|
||||
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
|
||||
Route::with_handler_and_name(
|
||||
"/testimonial-image/{id}",
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
:root {
|
||||
--admin-accent: #7567e8;
|
||||
--admin-accent-dark: #6255cf;
|
||||
--admin-accent-soft: #efedff;
|
||||
--admin-bg: #f6f5f9;
|
||||
--admin-surface: #fff;
|
||||
--admin-surface-soft: #fbfaff;
|
||||
--admin-border: #e7e5ef;
|
||||
--admin-border-soft: #eeecf3;
|
||||
--admin-text: #302c49;
|
||||
--admin-muted: #817c94;
|
||||
--admin-danger: #b54e5b;
|
||||
--admin-success: #177349;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { background: var(--admin-bg); }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--admin-bg);
|
||||
color: var(--admin-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
a { color: var(--admin-accent-dark); }
|
||||
|
||||
/* Application shell */
|
||||
.admin-shell { padding-bottom: 4.35rem; }
|
||||
|
||||
.top-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 3.5rem;
|
||||
padding: .55rem 1rem;
|
||||
background: rgba(255,255,255,.94);
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
box-shadow: 0 1px 8px rgba(42,34,80,.035);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.top-header .brand {
|
||||
flex: 0 0 auto;
|
||||
color: var(--admin-text);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
color: #aaa5b6;
|
||||
font-size: .66rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.top-header-right {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: .7rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.top-header-right a { color: #777184; text-decoration: none; }
|
||||
.top-header-right a:hover { color: var(--admin-accent-dark); }
|
||||
.top-header-right .admin-name { color: #aaa5b6; }
|
||||
|
||||
.desktop-nav { display: none; }
|
||||
|
||||
.bottom-tabs {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
height: 3.65rem;
|
||||
overflow-x: auto;
|
||||
background: rgba(255,255,255,.96);
|
||||
border-top: 1px solid var(--admin-border);
|
||||
box-shadow: 0 -4px 18px rgba(42,34,80,.06);
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.bottom-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.bottom-tabs a {
|
||||
display: flex;
|
||||
flex: 1 0 3.2rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 3.2rem;
|
||||
padding: 0 .42rem;
|
||||
color: #9993a8;
|
||||
font-size: .59rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottom-tabs a .tab-icon { font-size: 1.2rem; line-height: 1; }
|
||||
.bottom-tabs a .tab-label { display: block; margin-top: .12rem; }
|
||||
.bottom-tabs a.is-active { color: var(--admin-accent); }
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 2rem;
|
||||
}
|
||||
|
||||
/* Typography and page chrome */
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: .8rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-head h1 {
|
||||
margin: 0;
|
||||
color: var(--admin-text);
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
|
||||
.page-head p,
|
||||
.page-description {
|
||||
margin: .25rem 0 0;
|
||||
color: var(--admin-muted);
|
||||
font-size: .86rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.page-head-actions,
|
||||
.toolbar,
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 1.55rem 0 .75rem;
|
||||
color: #514b69;
|
||||
font-size: .82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-inline-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .75rem; }
|
||||
.section-inline-head .label { margin: 0; }
|
||||
.form-card hr { height: 1px; margin: 1rem 0; background: var(--admin-border-soft); border: 0; }
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem 1rem;
|
||||
color: var(--admin-muted);
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,.65);
|
||||
border: 1px dashed #dcd8e8;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button {
|
||||
border-radius: 8px;
|
||||
box-shadow: none !important;
|
||||
font-weight: 700;
|
||||
transition: transform .12s, border-color .15s, background .15s;
|
||||
}
|
||||
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button:active { transform: none; }
|
||||
.button.is-primary { background: var(--admin-accent); border-color: var(--admin-accent); }
|
||||
.button.is-primary:not(.is-outlined):not(.is-light),
|
||||
.button.is-link:not(.is-outlined):not(.is-light),
|
||||
.button.is-info:not(.is-outlined):not(.is-light),
|
||||
.button.is-success:not(.is-outlined):not(.is-light),
|
||||
.button.is-danger:not(.is-outlined):not(.is-light) { color: #fff !important; }
|
||||
.button.is-primary:hover { color: #fff !important; background: var(--admin-accent-dark); border-color: var(--admin-accent-dark); }
|
||||
.button.is-primary.is-outlined { color: var(--admin-accent-dark) !important; background: transparent; border-color: #a49bea; }
|
||||
.button.is-primary.is-outlined:hover { color: #fff !important; background: var(--admin-accent); border-color: var(--admin-accent); }
|
||||
.button.is-light { background: #f3f1f8; color: #5f5971; }
|
||||
.button[disabled] { color: #8d8798 !important; background: #eceaf0 !important; border-color: #e0dde7 !important; opacity: 1; transform: none; }
|
||||
.button.is-small,
|
||||
.btn-sm { height: auto !important; padding: .32rem .65rem !important; font-size: .75rem !important; }
|
||||
|
||||
/* Reusable cards and sections */
|
||||
.admin-section,
|
||||
.form-card {
|
||||
overflow: hidden;
|
||||
background: var(--admin-surface);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(42,34,80,.035);
|
||||
}
|
||||
|
||||
.form-card { padding: 1.2rem; }
|
||||
|
||||
.admin-section-head {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0,1fr);
|
||||
gap: .8rem;
|
||||
padding: 1rem 1.15rem;
|
||||
background: linear-gradient(180deg,#fff,var(--admin-surface-soft));
|
||||
border-bottom: 1px solid #eceaf2;
|
||||
}
|
||||
|
||||
.admin-section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: var(--admin-accent-soft);
|
||||
border-radius: 11px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.admin-section-head h2 {
|
||||
margin: 0;
|
||||
color: var(--admin-text);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.admin-section-head p {
|
||||
max-width: 680px;
|
||||
margin: .2rem 0 0;
|
||||
color: var(--admin-muted);
|
||||
font-size: .82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.admin-section-body { padding: 1.15rem; }
|
||||
|
||||
.admin-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: .12rem .48rem;
|
||||
color: #777184;
|
||||
background: #eeedf2;
|
||||
border-radius: 99px;
|
||||
font-size: .67rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-pill.is-enabled { color: var(--admin-success); background: #dcf7e8; }
|
||||
|
||||
.admin-grid,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2,minmax(0,1fr));
|
||||
gap: 1rem 1.1rem;
|
||||
}
|
||||
.form-grid > .field { margin: 0; }
|
||||
.page-head .button.is-primary { min-height: 2.25rem; padding: .42rem .8rem !important; }
|
||||
|
||||
.admin-field { min-width: 0; margin: 0 !important; }
|
||||
.admin-field-wide { grid-column: 1 / -1; }
|
||||
|
||||
.admin-subsection {
|
||||
margin-top: 1.1rem;
|
||||
padding-top: 1.1rem;
|
||||
border-top: 1px solid var(--admin-border-soft);
|
||||
}
|
||||
|
||||
.admin-subsection h2,
|
||||
.admin-subsection h3,
|
||||
.form-card > h2 {
|
||||
margin: 0 0 .75rem;
|
||||
color: #514b69;
|
||||
font-size: .82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
display: block;
|
||||
margin-bottom: .65rem;
|
||||
padding: .9rem 1rem;
|
||||
color: inherit;
|
||||
background: var(--admin-surface);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 6px rgba(42,34,80,.025);
|
||||
transition: border-color .15s, box-shadow .15s, transform .15s;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
border-color: #dcd8eb;
|
||||
box-shadow: 0 5px 18px rgba(42,34,80,.055);
|
||||
}
|
||||
|
||||
.item-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: .75rem;
|
||||
margin-bottom: .35rem;
|
||||
}
|
||||
|
||||
.item-card-header .name {
|
||||
min-width: 0;
|
||||
color: var(--admin-text);
|
||||
font-size: .95rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.item-card-meta {
|
||||
display: flex;
|
||||
gap: .25rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
color: var(--admin-muted);
|
||||
font-size: .79rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.item-card-meta a { color: inherit; text-decoration: none; }
|
||||
|
||||
.item-card-actions {
|
||||
display: flex;
|
||||
gap: .4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: .65rem;
|
||||
padding-top: .6rem;
|
||||
border-top: 1px solid var(--admin-border-soft);
|
||||
}
|
||||
|
||||
.item-card-actions form { margin: 0; }
|
||||
.color-dot { display: inline-block; width: 11px; height: 11px; margin-right: 6px; border-radius: 50%; vertical-align: middle; }
|
||||
.card-link { color: inherit; text-decoration: none; }
|
||||
.card-note { margin-top: .35rem; color: var(--admin-muted); font-size: .78rem; line-height: 1.45; }
|
||||
.feedback-card { border-left: 3px solid var(--admin-accent); text-decoration: none; }
|
||||
.feedback-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .3rem; }
|
||||
.feedback-date { color: #9993a8; font-size: .75rem; white-space: nowrap; }
|
||||
.feedback-text { color: #4a4570; font-size: .82rem; line-height: 1.5; }
|
||||
.muted-detail { color: var(--admin-muted); font-weight: 450; }
|
||||
|
||||
/* Statuses */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
padding: .16rem .58rem;
|
||||
border-radius: 99px;
|
||||
font-size: .69rem;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-new,
|
||||
.badge-visit-scheduled { color: #1e40af; background: #dbeafe; }
|
||||
.badge-in_progress { color: #92400e; background: #fef3c7; }
|
||||
.badge-converted,
|
||||
.badge-active,
|
||||
.badge-visit-completed { color: #065f46; background: #d1fae5; }
|
||||
.badge-rejected { color: #991b1b; background: #fee2e2; }
|
||||
.badge-archived,
|
||||
.badge-visit-cancelled { color: #554f61; background: #ebe9ef; }
|
||||
|
||||
/* Forms */
|
||||
.field:not(:last-child) { margin-bottom: 1rem; }
|
||||
.field .label,
|
||||
.admin-field .label {
|
||||
margin-bottom: .38rem;
|
||||
color: #514c66 !important;
|
||||
font-size: .76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
.input,
|
||||
.textarea,
|
||||
.select select {
|
||||
color: var(--admin-text) !important;
|
||||
background-color: #fff !important;
|
||||
border-color: #dcd9e5 !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: none !important;
|
||||
font-size: .86rem;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select select { min-height: 2.45rem; }
|
||||
.textarea { min-height: 76px; resize: vertical; }
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
.input:focus,
|
||||
.textarea:focus,
|
||||
.select select:focus {
|
||||
border-color: #7c6cff !important;
|
||||
box-shadow: 0 0 0 2px rgba(124,108,255,.12) !important;
|
||||
}
|
||||
|
||||
input[readonly],
|
||||
.input[readonly] { color: #716b81 !important; background: #f8f7fa !important; }
|
||||
|
||||
input[type="file"].input { padding: .36rem .55rem; }
|
||||
input[type="color"] { width: 3rem; height: 2.35rem; padding: 2px; cursor: pointer; }
|
||||
input[type="checkbox"] { accent-color: var(--admin-accent); }
|
||||
|
||||
.field-help {
|
||||
margin-top: .35rem;
|
||||
color: #8b869b;
|
||||
font-size: .72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.secret-control { position: relative; }
|
||||
.secret-control::after { position: absolute; top: 50%; right: .75rem; content: "🔒"; transform: translateY(-50%); opacity: .48; font-size: .78rem; pointer-events: none; }
|
||||
.secret-control .input { padding-right: 2.25rem; letter-spacing: .05em; }
|
||||
.admin-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.admin-toggle-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .7rem; margin-bottom: 1rem; }
|
||||
.admin-toggle { position: relative; display: flex; align-items: flex-start; gap: .7rem; padding: .75rem .85rem; color: inherit !important; background: var(--admin-surface-soft); border: 1px solid #e8e5f2; border-radius: 10px; cursor: pointer; transition: border-color .15s,background .15s; }
|
||||
.admin-toggle:hover { background: #f8f6ff; border-color: #dcd7ef; }
|
||||
.admin-toggle > input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.admin-toggle-track { flex: 0 0 auto; width: 38px; height: 22px; padding: 2px; background: #ccc8d6; border-radius: 99px; transition: .18s; }
|
||||
.admin-toggle-track span { display: block; width: 18px; height: 18px; background: #fff; border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,.2); transition: .18s; }
|
||||
.admin-toggle > input:checked + .admin-toggle-track { background: var(--admin-accent); }
|
||||
.admin-toggle > input:checked + .admin-toggle-track span { transform: translateX(16px); }
|
||||
.admin-toggle > input:focus-visible + .admin-toggle-track { outline: 2px solid #7c6cff; outline-offset: 2px; }
|
||||
.admin-toggle strong { display: block; color: #3e3956; font-size: .82rem; line-height: 1.35; }
|
||||
.admin-toggle small { display: block; margin-top: .15rem; color: #8a849c; font-size: .7rem; line-height: 1.35; }
|
||||
.admin-toggle-compact { align-items: center; padding: .55rem .65rem; }
|
||||
|
||||
.admin-check { position: relative; display: inline-flex; align-items: center; gap: .45rem; color: #716b81 !important; font-size: .72rem; line-height: 1.35; cursor: pointer; }
|
||||
.admin-check > input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.admin-check-box { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 18px; height: 18px; color: transparent; background: #fff; border: 1.5px solid #cfcad9; border-radius: 5px; font-size: .68rem; font-weight: 900; transition: .15s; }
|
||||
.admin-check > input:checked + .admin-check-box { color: #fff; background: var(--admin-accent); border-color: var(--admin-accent); }
|
||||
.admin-check > input:focus-visible + .admin-check-box { outline: 2px solid #7c6cff; outline-offset: 2px; }
|
||||
.admin-check-danger { color: #a04e58 !important; }
|
||||
.admin-check-danger > input:checked + .admin-check-box { background: var(--admin-danger); border-color: var(--admin-danger); }
|
||||
|
||||
.admin-note { margin: .9rem 0 1rem; padding: .75rem .9rem; border: 1px solid; border-radius: 9px; font-size: .76rem; line-height: 1.5; }
|
||||
.admin-note p + p { margin-top: .35rem; }
|
||||
.admin-note-info { color: #42627f; background: #f1f7ff; border-color: #d7e8fa; }
|
||||
.admin-note-warning { color: #785f28; background: #fff8e8; border-color: #f1dfb4; }
|
||||
.admin-note code { display: inline-block; margin: .2rem 0 0; padding: .12rem .35rem; color: inherit; background: rgba(255,255,255,.7); border-radius: 4px; user-select: all; }
|
||||
|
||||
.admin-details { margin-top: 1rem; overflow: hidden; background: #fcfbff; border: 1px solid #e8e5f2; border-radius: 9px; }
|
||||
.admin-details summary { display: flex; align-items: center; justify-content: space-between; padding: .7rem .85rem; color: #514b69; font-size: .78rem; font-weight: 700; cursor: pointer; }
|
||||
.admin-details summary span { min-width: 24px; padding: .05rem .4rem; color: #6257bb; text-align: center; background: #ece9ff; border-radius: 99px; }
|
||||
.admin-details-body { padding: 0 .85rem .85rem; }
|
||||
|
||||
.sticky-actions {
|
||||
position: sticky;
|
||||
bottom: 4.3rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: .65rem;
|
||||
background: rgba(255,255,255,.92);
|
||||
border: 1px solid rgba(222,218,238,.9);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 28px rgba(46,38,86,.12);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.sticky-actions .button { min-width: 150px; }
|
||||
|
||||
.danger-zone { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #f1dfe2; }
|
||||
.inline-control { display: flex; align-items: center; gap: .5rem; }
|
||||
.inline-control > .input,
|
||||
.inline-control > .select { flex: 1; min-width: 0; }
|
||||
.color-control { display: flex; align-items: center; gap: .6rem; }
|
||||
.portal-panel { margin-top: 1.1rem; padding-top: 1.1rem; border-top: 1px solid var(--admin-border-soft); }
|
||||
.portal-qr { margin-top: .8rem; text-align: center; }
|
||||
.portal-qr canvas { max-width: 180px; padding: .4rem; background: #fff; border: 1px solid var(--admin-border); border-radius: 10px; }
|
||||
.inline-admin-form { margin-top: .7rem; padding-top: .7rem; border-top: 1px solid var(--admin-border-soft); }
|
||||
.inline-admin-grid { display: grid; grid-template-columns: minmax(180px,1fr) minmax(180px,.8fr) auto; align-items: end; gap: .75rem; }
|
||||
.inline-admin-grid .field { margin: 0; }
|
||||
.testimonial-content { display: flex; align-items: flex-start; gap: .75rem; min-width: 0; }
|
||||
.testimonial-avatar { flex: 0 0 auto; width: 48px; height: 48px; border-radius: 12px; object-fit: cover; }
|
||||
.testimonial-text { color: #49435d; font-size: .88rem; line-height: 1.5; }
|
||||
.testimonial-note { margin-top: .2rem; color: var(--admin-muted); font-size: .76rem; }
|
||||
|
||||
/* Messages and tables */
|
||||
.notification { color: var(--admin-text) !important; border-radius: 10px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.table-wrap .table { margin: 0; background: transparent; font-size: .75rem; }
|
||||
.table th { color: #696278 !important; font-size: .7rem; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.is-nowrap { white-space: nowrap; }
|
||||
.seo-preview { min-height: 2rem; margin-top: .5rem; padding: .5rem .65rem; background: #fafafa; border: 1px solid #ebe9ef; border-radius: 7px; font-size: .78rem; line-height: 2; }
|
||||
|
||||
.pagination-row,
|
||||
.media-pagination { display: flex; justify-content: center; gap: .35rem; flex-wrap: wrap; margin-top: 1.25rem; }
|
||||
|
||||
/* Media */
|
||||
.filter-bar { display: flex; align-items: center; gap: .6rem; margin-bottom: 1rem; padding: .65rem .75rem; background: #fff; border: 1px solid var(--admin-border); border-radius: 10px; }
|
||||
.media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(200px,1fr)); gap: .8rem; }
|
||||
.media-card { overflow: hidden; background: #fff; border: 1px solid var(--admin-border); border-radius: 12px; box-shadow: 0 1px 6px rgba(42,34,80,.025); transition: transform .15s, box-shadow .15s; }
|
||||
.media-card:hover { transform: translateY(-2px); box-shadow: 0 7px 20px rgba(42,34,80,.08); }
|
||||
.media-card img { display: block; width: 100%; height: 160px; object-fit: cover; }
|
||||
.media-card .photo-thumb { width: 100%; height: 160px; }
|
||||
.media-card .video-thumb { position: relative; width: 100%; height: 160px; background: #17151e; }
|
||||
.media-card .video-play,
|
||||
.visit-media-item .video-play { position: absolute; top: 50%; left: 50%; color: #fff; line-height: 1; text-shadow: 0 1px 5px #000; transform: translate(-50%,-50%); pointer-events: none; }
|
||||
.media-card .video-play { font-size: 2.5rem; }
|
||||
.media-info { padding: .7rem .8rem; }
|
||||
.media-meta { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: var(--admin-text); font-size: .82rem; }
|
||||
.media-caption { margin-top: .25rem; color: #746e82; font-size: .78rem; }
|
||||
|
||||
.visit-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(105px,1fr)); gap: .55rem; margin-bottom: 1rem; }
|
||||
.visit-media-item { overflow: hidden; background: #faf9fc; border: 1px solid var(--admin-border); border-radius: 9px; }
|
||||
.visit-media-item img,
|
||||
.visit-media-item .photo-thumb,
|
||||
.visit-media-item .video-thumb-sm { display: block; width: 100%; height: 82px; object-fit: cover; }
|
||||
.visit-media-item .video-thumb-sm { position: relative; background: #17151e; }
|
||||
.visit-media-item .video-play { font-size: 1.6rem; }
|
||||
.visit-media-item .media-cap { padding: .25rem .4rem; overflow: hidden; color: var(--admin-muted); font-size: .69rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.visit-media-delete { padding: .25rem .4rem .4rem; }
|
||||
.visit-media-delete .button { width: 100%; min-height: 1.65rem; font-size: .66rem; }
|
||||
|
||||
/* Upload queue */
|
||||
.upload-context { margin-bottom: 1rem; padding: .7rem .8rem; color: #686276; background: var(--admin-surface-soft); border: 1px solid var(--admin-border-soft); border-radius: 9px; font-size: .82rem; }
|
||||
.upload-queue { display: flex; flex-direction: column; gap: .4rem; margin-top: .65rem; }
|
||||
.upload-queue:empty { display: none; }
|
||||
.upload-queue-item { display: grid; grid-template-columns: 34px minmax(0,1fr); align-items: center; gap: .55rem; padding: .45rem .55rem; background: #f7f6ff; border-radius: 8px; }
|
||||
.upload-queue-icon { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; background: #ebe8ff; border-radius: 7px; font-size: 1.05rem; }
|
||||
.upload-queue-name { overflow: hidden; color: #494467; font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.upload-queue-state { color: #8a84a5; font-size: .68rem; }
|
||||
.upload-queue-track { height: 3px; margin-top: .25rem; overflow: hidden; background: #dedbea; border-radius: 99px; }
|
||||
.upload-queue-bar { width: 0; height: 100%; background: var(--admin-accent); transition: width .2s; }
|
||||
.upload-progress { display: none; margin-bottom: 1rem; }
|
||||
.upload-progress-head { display: flex; justify-content: space-between; margin-bottom: .3rem; color: #5f596d; font-size: .78rem; }
|
||||
.upload-progress-track { height: 7px; overflow: hidden; background: #e8e6ed; border-radius: 99px; }
|
||||
.upload-progress-bar { width: 0; height: 100%; background: linear-gradient(90deg,#6c63ff,#b06cff); border-radius: 99px; transition: width .2s; }
|
||||
#uploadBar.is-processing,
|
||||
.upload-queue-bar.is-processing { width: 35% !important; animation: upload-processing 1.15s ease-in-out infinite; }
|
||||
@keyframes upload-processing { from { transform: translateX(-110%); } to { transform: translateX(300%); } }
|
||||
|
||||
/* Schedule */
|
||||
#calendar-wrap { padding: .75rem; overflow: hidden; }
|
||||
.fc { color: var(--admin-text); font-size: .82rem; }
|
||||
.fc .fc-daygrid-day-number,
|
||||
.fc .fc-col-header-cell-cushion,
|
||||
.fc .fc-list-day-text,
|
||||
.fc .fc-list-day-side-text,
|
||||
.fc .fc-toolbar-title,
|
||||
.fc th,
|
||||
.fc td { color: var(--admin-text) !important; }
|
||||
.fc .fc-toolbar { gap: .55rem; flex-wrap: nowrap; }
|
||||
.fc .fc-toolbar-chunk { min-width: 0; }
|
||||
.fc .fc-toolbar-chunk:first-child,
|
||||
.fc .fc-toolbar-chunk:last-child { display: flex; align-items: center; }
|
||||
.fc .fc-button-group { display: flex; flex-wrap: nowrap; gap: .35rem; }
|
||||
.fc .fc-button-group > .fc-button { margin-left: 0 !important; border-radius: 7px !important; }
|
||||
.fc .fc-toolbar-title { font-size: 1.05rem !important; white-space: nowrap; }
|
||||
.fc .fc-button { padding: .28rem .52rem !important; color: #fff !important; background: var(--admin-accent) !important; border-color: var(--admin-accent) !important; border-radius: 7px !important; font-size: .76rem !important; }
|
||||
.fc-event { padding: 2px 5px; border: none !important; border-radius: 5px; cursor: pointer; }
|
||||
.fc-event.ev-completed .fc-event-title,
|
||||
.fc-event.ev-completed .fc-list-event-title,
|
||||
.fc-event.ev-cancelled .fc-event-title,
|
||||
.fc-event.ev-cancelled .fc-list-event-title { text-decoration: line-through; opacity: .75; }
|
||||
.fc .fc-day-today { background: #f0eeff !important; }
|
||||
.fc .fc-day.day-weekend { background: #fdf9f6; }
|
||||
.fc .fc-day-today.day-weekend { background: #f0eeff !important; }
|
||||
|
||||
.time-row { display: flex; align-items: center; gap: .5rem; }
|
||||
.time-block { display: flex; flex: 1; flex-direction: column; }
|
||||
.time-lbl { margin-bottom: .2rem; color: var(--admin-muted); font-size: .72rem; }
|
||||
.time-sep { padding-top: 1.2rem; color: #aaa5b6; font-size: 1.1rem; }
|
||||
.cal-toolbar { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .55rem; }
|
||||
.sched-cal { overflow: hidden; background: #faf9fc; border: 1px solid var(--admin-border); border-radius: 11px; }
|
||||
.cal-nav { display: flex; align-items: center; justify-content: space-between; padding: .65rem .75rem; background: #fff; border-bottom: 1px solid var(--admin-border-soft); }
|
||||
.cal-nav button { padding: .2rem .5rem; color: var(--admin-accent); background: none; border: 0; border-radius: 6px; font-size: 1.05rem; cursor: pointer; }
|
||||
.cal-nav button:hover { background: #f0eeff; }
|
||||
.cal-nav span { color: var(--admin-text); font-size: .95rem; font-weight: 750; }
|
||||
.cal-grid { display: grid; grid-template-columns: repeat(7,1fr); }
|
||||
.cal-wday { padding: .42rem 0; color: #aaa5b6; background: #faf9fc; font-size: .69rem; font-weight: 750; text-align: center; }
|
||||
.cal-wday.is-weekend { color: #d98a91; }
|
||||
.cal-day { position: relative; padding: .55rem .2rem; color: var(--admin-text); border-radius: 0; font-size: .86rem; text-align: center; cursor: pointer; transition: background .1s; user-select: none; }
|
||||
.cal-day:hover { background: #f0eeff; }
|
||||
.cal-day.is-empty { cursor: default; }
|
||||
.cal-day.is-empty:hover { background: none; }
|
||||
.cal-day.is-today { color: var(--admin-accent); font-weight: 750; }
|
||||
.cal-day.is-selected { color: #fff !important; background: var(--admin-accent) !important; }
|
||||
.cal-day.is-range-start { color: #fff !important; background: #a89cff !important; }
|
||||
.cal-day.is-past { color: #ccc8d3; }
|
||||
.day-row { display: flex; align-items: center; gap: .4rem; padding: .5rem 0; border-bottom: 1px solid #f2f0f5; }
|
||||
.day-row:last-child { border-bottom: 0; }
|
||||
.day-date { flex: 1; min-width: 0; overflow: hidden; font-size: .82rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.day-times { display: flex; flex-shrink: 0; align-items: center; gap: .25rem; }
|
||||
.day-times .time-sep { padding: 0; font-size: .78rem; }
|
||||
.day-rm { flex-shrink: 0; padding: .15rem .25rem; color: #bbb6c5; background: none; border: 0; font-size: 1rem; line-height: 1; cursor: pointer; }
|
||||
.day-rm:hover { color: var(--admin-danger); }
|
||||
.time-badge-wrap { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 3.6rem; padding: .15rem .5rem; color: #5b52d6; background: #ede9ff; border: 1.5px solid #c4beff; border-radius: 20px; font-size: .76rem; font-weight: 750; white-space: nowrap; cursor: pointer; }
|
||||
.time-badge-wrap:focus-within { background: #f0eeff; border-color: var(--admin-accent); }
|
||||
.time-badge-label { z-index: 1; pointer-events: none; }
|
||||
.time-badge-input { position: absolute; inset: 0; width: 100%; height: 100%; padding: 0; margin: 0; opacity: 0; background: transparent; border: 0; font-size: 16px; cursor: pointer; }
|
||||
|
||||
.status-picker { display: flex; gap: .5rem; }
|
||||
.status-btn { display: flex; flex: 1; flex-direction: column; align-items: center; gap: .3rem; padding: .6rem .4rem; color: #777184; background: #f5f4f7; border: 2px solid transparent; border-radius: 10px; font-size: .79rem; font-weight: 650; line-height: 1.2; cursor: pointer; transition: all .15s; }
|
||||
.status-btn-icon { font-size: 1.3rem; line-height: 1; }
|
||||
.status-btn:hover { filter: brightness(.97); }
|
||||
.status-btn-scheduled.is-active { color: #1e40af; background: #dbeafe; border-color: #3b82f6; }
|
||||
.status-btn-completed.is-active { color: #15803d; background: #d1fae5; border-color: #22c55e; }
|
||||
.status-btn-cancelled.is-active { color: #b91c1c; background: #fee2e2; border-color: #ef4444; }
|
||||
|
||||
/* Dialogs */
|
||||
.visit-modal-bg,
|
||||
.upload-modal-bg { position: fixed; inset: 0; z-index: 100; display: none; align-items: center; justify-content: center; padding: 1rem; background: rgba(31,27,48,.38); backdrop-filter: blur(3px); }
|
||||
.visit-modal-bg.is-open,
|
||||
.upload-modal-bg.is-open { display: flex; }
|
||||
.visit-modal,
|
||||
.upload-modal { width: 100%; max-width: 420px; padding: 1.25rem; background: #fff; border: 1px solid var(--admin-border); border-radius: 14px; box-shadow: 0 18px 50px rgba(31,27,48,.18); }
|
||||
.visit-modal { max-width: 380px; }
|
||||
.visit-modal h3 { margin: 0 0 .75rem; color: var(--admin-text); font-size: 1.05rem; font-weight: 750; }
|
||||
.visit-modal .meta { margin-bottom: .75rem; color: var(--admin-muted); font-size: .82rem; line-height: 1.6; }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: 1rem; }
|
||||
.modal-head h3 { margin: 0; color: var(--admin-text); font-size: 1.05rem; font-weight: 750; }
|
||||
.modal-close { padding: .25rem; color: var(--admin-muted); background: none; border: 0; font-size: 1.15rem; cursor: pointer; }
|
||||
|
||||
/* Authentication screens */
|
||||
.auth-shell { display: flex; align-items: center; justify-content: center; padding: 1.5rem 0; }
|
||||
.login-box { width: 100%; max-width: 410px; padding: 0 1rem; }
|
||||
.auth-language { margin-bottom: .7rem; text-align: right; }
|
||||
.auth-language a { color: var(--admin-muted); font-size: .75rem; }
|
||||
.login-card { padding: 1.6rem 1.4rem; background: #fff; border: 1px solid var(--admin-border); border-radius: 16px; box-shadow: 0 12px 38px rgba(42,34,80,.08); }
|
||||
.auth-head { margin-bottom: 1.1rem; text-align: center; }
|
||||
.auth-icon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; margin: 0 auto .65rem; background: var(--admin-accent-soft); border-radius: 13px; font-size: 1.55rem; }
|
||||
.auth-head h1 { margin: 0; color: var(--admin-text); font-size: 1.25rem; font-weight: 800; }
|
||||
.auth-head p { margin-top: .2rem; color: var(--admin-muted); font-size: .84rem; }
|
||||
.auth-divider { height: 1px; margin: 1rem 0; background: var(--admin-border-soft); border: 0; }
|
||||
.turnstile-wrap { margin-top: .75rem; }
|
||||
.time-columns { margin-bottom: 0 !important; }
|
||||
|
||||
@media (min-width: 1080px) {
|
||||
.admin-shell { padding-bottom: 0; }
|
||||
.bottom-tabs { display: none; }
|
||||
.desktop-nav { display: flex; align-items: center; justify-content: center; gap: .2rem; }
|
||||
.desktop-nav a { padding: .36rem .62rem; color: #625d70; border-radius: 7px; font-size: .78rem; font-weight: 650; text-decoration: none; transition: background .15s,color .15s; }
|
||||
.desktop-nav a:hover { background: #f2f0f7; }
|
||||
.desktop-nav a.is-active { color: #fff; background: var(--admin-accent); }
|
||||
.sticky-actions { bottom: .75rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.top-header .brand { font-size: .95rem; }
|
||||
.brand-version,
|
||||
.top-header-right .admin-name { display: none; }
|
||||
.main-content { padding: 1rem .75rem 1.5rem; }
|
||||
.page-head { align-items: flex-start; }
|
||||
.page-head h1 { font-size: 1.35rem; }
|
||||
.admin-grid,
|
||||
.form-grid,
|
||||
.admin-toggle-list { grid-template-columns: 1fr; }
|
||||
.admin-field-wide { grid-column: auto; }
|
||||
.admin-section-head { grid-template-columns: 36px minmax(0,1fr); padding: .9rem; }
|
||||
.admin-section-icon { width: 36px; height: 36px; }
|
||||
.admin-section-body,
|
||||
.form-card { padding: .9rem; }
|
||||
.sticky-actions .button { width: 100%; }
|
||||
.fc .fc-toolbar { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: .55rem; font-size: .72rem; }
|
||||
.fc .fc-toolbar-chunk:first-child { grid-column: 1; grid-row: 1; justify-self: start; }
|
||||
.fc .fc-toolbar-chunk:nth-child(2) { grid-column: 2; grid-row: 1; justify-self: end; }
|
||||
.fc .fc-toolbar-chunk:last-child { grid-column: 1 / -1; grid-row: 2; width: 100%; overflow-x: auto; }
|
||||
.fc .fc-toolbar-chunk:last-child .fc-button-group { width: 100%; }
|
||||
.fc .fc-toolbar-chunk:last-child .fc-button { flex: 1 0 auto; }
|
||||
.fc .fc-toolbar-title { font-size: .92rem !important; }
|
||||
.fc .fc-button { padding: .22rem .35rem !important; font-size: .68rem !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.page-head { flex-direction: column; }
|
||||
.page-head-actions { width: 100%; }
|
||||
.page-head-actions .button { flex: 1; }
|
||||
.item-card-header { align-items: flex-start; }
|
||||
.media-grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: .55rem; }
|
||||
.media-card img,
|
||||
.media-card .photo-thumb,
|
||||
.media-card .video-thumb { height: 122px; }
|
||||
.media-meta { align-items: flex-start; flex-direction: column; gap: .1rem; }
|
||||
.status-picker { flex-direction: column; }
|
||||
.status-btn { flex-direction: row; justify-content: flex-start; }
|
||||
.inline-control { align-items: stretch; flex-direction: column; }
|
||||
.inline-admin-grid { grid-template-columns: 1fr; align-items: stretch; }
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.bottom-tabs a .tab-label { display: none; }
|
||||
.bottom-tabs a { min-width: 2.8rem; padding: 0 .3rem; }
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/chart.js@4.4.6/dist/chart.umd.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
/*!
|
||||
* Chart.js v4.4.6
|
||||
* https://www.chartjs.org
|
||||
* (c) 2024 Chart.js Contributors
|
||||
* Released under the MIT License
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?b(n,a,s):e[t]=g(a)}function b(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function x(t,e){return b(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?x(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function Z(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function J(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return J(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){Line truncated
|
||||
/*!
|
||||
* @kurkle/color v0.3.2
|
||||
* https://github.com/kurkle/color#readme
|
||||
* (c) 2023 Jukka Kurkela
|
||||
* Released under the MIT License
|
||||
*/function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
|
||||
//# sourceMappingURL=chart.umd.js.map
|
||||
@@ -0,0 +1,668 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% let active_page = "analytics" %}
|
||||
|
||||
{% block title %}{{ t.analytics_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>{{ t.analytics_title }}</h1>
|
||||
<p>{{ t.analytics_intro }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-controls"
|
||||
data-labels='{
|
||||
"landing_views": "{{ t.analytics_metric_landing_views }}",
|
||||
"landing_unique": "{{ t.analytics_metric_landing_unique }}",
|
||||
"portal_opens": "{{ t.analytics_metric_portal_opens }}",
|
||||
"media_views": "{{ t.analytics_metric_media_views }}",
|
||||
"no_data": "{{ t.analytics_no_data }}",
|
||||
"loading": "{{ t.analytics_loading }}",
|
||||
"error": "{{ t.analytics_load_error }}",
|
||||
"log_empty": "{{ t.analytics_log_empty }}",
|
||||
"log_end": "{{ t.analytics_log_end }}",
|
||||
"visitor": "{{ t.analytics_log_visitor }}",
|
||||
"landing_view": "{{ t.analytics_ev_landing_view }}",
|
||||
"portal_open": "{{ t.analytics_ev_portal_open }}",
|
||||
"media_view": "{{ t.analytics_ev_media_view }}",
|
||||
"media_prefix": "{{ t.analytics_log_media_prefix }}"
|
||||
}'>
|
||||
<div class="analytics-presets">
|
||||
<button type="button" class="button is-small preset-btn" data-days="7">{{ t.analytics_preset_7 }}</button>
|
||||
<button type="button" class="button is-small preset-btn" data-days="30">{{ t.analytics_preset_30 }}</button>
|
||||
<button type="button" class="button is-small preset-btn" data-days="90">{{ t.analytics_preset_90 }}</button>
|
||||
<button type="button" class="button is-small preset-btn" data-days="365">{{ t.analytics_preset_365 }}</button>
|
||||
</div>
|
||||
<div class="analytics-range">
|
||||
<label>{{ t.analytics_range_from }}
|
||||
<input type="date" id="anFrom" class="input is-small">
|
||||
</label>
|
||||
<label>{{ t.analytics_range_to }}
|
||||
<input type="date" id="anTo" class="input is-small">
|
||||
</label>
|
||||
<label>{{ t.analytics_granularity }}
|
||||
<select id="anGran" class="input is-small">
|
||||
<option value="day">{{ t.analytics_gran_day }}</option>
|
||||
<option value="week">{{ t.analytics_gran_week }}</option>
|
||||
<option value="month">{{ t.analytics_gran_month }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-kpis" id="anKpis"></div>
|
||||
|
||||
<div class="analytics-chart-wrap">
|
||||
<div class="analytics-chart-box">
|
||||
<canvas id="anChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-tops">
|
||||
<div class="analytics-top-card">
|
||||
<h2>{{ t.analytics_top_clients }}</h2>
|
||||
<table class="analytics-top-table" id="anTopClients">
|
||||
<thead><tr><th>{{ t.analytics_top_clients }}</th><th>{{ t.analytics_count }}</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="analytics-top-card">
|
||||
<h2>{{ t.analytics_top_media }}</h2>
|
||||
<table class="analytics-top-table" id="anTopMedia">
|
||||
<thead><tr><th>{{ t.analytics_top_media }}</th><th>{{ t.analytics_count }}</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-log-card">
|
||||
<div class="analytics-log-head">
|
||||
<h2>{{ t.analytics_log_title }}</h2>
|
||||
<div class="analytics-log-filters">
|
||||
<label class="log-chip" title="{{ t.analytics_ev_landing_view }}">
|
||||
<input type="checkbox" class="log-type-toggle" data-type="landing_view">
|
||||
<span>🌐 {{ t.analytics_ev_landing_short }}</span>
|
||||
</label>
|
||||
<label class="log-chip" title="{{ t.analytics_ev_portal_open }}">
|
||||
<input type="checkbox" class="log-type-toggle" data-type="portal_open" checked>
|
||||
<span>👤 {{ t.analytics_ev_portal_short }}</span>
|
||||
</label>
|
||||
<label class="log-chip" title="{{ t.analytics_ev_media_view }}">
|
||||
<input type="checkbox" class="log-type-toggle" data-type="media_view" checked>
|
||||
<span>🖼️ {{ t.analytics_ev_media_short }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analytics-log" id="anLog"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.analytics-controls { display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end; margin-bottom:18px; }
|
||||
.analytics-presets { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.analytics-range { display:flex; gap:12px; flex-wrap:wrap; }
|
||||
.analytics-range label { display:flex; flex-direction:column; font-size:.8rem; color:#666; gap:3px; }
|
||||
.analytics-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:18px; }
|
||||
.analytics-kpi { background:#fff; border-radius:12px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.06); border-left:4px solid var(--kpi-color,#7c6ed4); }
|
||||
.analytics-kpi .kpi-value { font-size:1.7rem; font-weight:700; line-height:1.1; }
|
||||
.analytics-kpi .kpi-label { font-size:.8rem; color:#777; margin-top:2px; }
|
||||
.analytics-metric-toggles { display:flex; flex-wrap:wrap; gap:14px; align-items:center; margin-bottom:12px; font-size:.85rem; }
|
||||
.analytics-metric-toggles .toggles-label { color:#777; font-weight:600; }
|
||||
.analytics-metric-toggles label { display:flex; align-items:center; gap:5px; cursor:pointer; }
|
||||
.analytics-chart-wrap { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-bottom:20px; }
|
||||
.analytics-chart-box { position:relative; height:340px; width:100%; }
|
||||
@media (max-width:600px) { .analytics-chart-box { height:260px; } }
|
||||
.analytics-tops { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px; }
|
||||
.analytics-top-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||
.analytics-top-card h2 { font-size:1rem; margin:0 0 10px; }
|
||||
.analytics-top-table { width:100%; border-collapse:collapse; font-size:.9rem; }
|
||||
.analytics-top-table th { text-align:left; color:#888; font-weight:600; font-size:.78rem; border-bottom:1px solid #eee; padding:6px 4px; }
|
||||
.analytics-top-table td { padding:6px 4px; border-bottom:1px solid #f4f4f4; }
|
||||
.analytics-top-table td:last-child, .analytics-top-table th:last-child { text-align:right; width:70px; }
|
||||
.analytics-top-empty { color:#aaa; padding:10px 4px; }
|
||||
.an-media-link { display:flex; align-items:center; gap:8px; color:inherit; text-decoration:none; }
|
||||
.an-media-link:hover { text-decoration:underline; }
|
||||
.an-media-thumb { width:40px; height:40px; border-radius:6px; object-fit:cover; background:#eceaf5; flex:0 0 auto; }
|
||||
.an-media-missing { width:40px; height:40px; border-radius:6px; background:#f0f0f3; display:inline-flex; align-items:center; justify-content:center; color:#bbb; flex:0 0 auto; }
|
||||
|
||||
.analytics-log-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-top:20px; }
|
||||
.analytics-log-card h2 { font-size:1rem; margin:0; }
|
||||
.analytics-log-head { display:flex; flex-wrap:wrap; gap:10px 18px; align-items:center; justify-content:space-between; margin-bottom:10px; }
|
||||
.analytics-log-filters { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.log-chip { display:inline-flex; align-items:center; gap:5px; padding:4px 11px; border-radius:999px; border:1px solid var(--admin-border,#e0e0e6); background:#f5f5f7; color:#8a8a95; font-size:.8rem; line-height:1.3; cursor:pointer; user-select:none; white-space:nowrap; transition:background .12s,color .12s,border-color .12s; }
|
||||
.log-chip input { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
|
||||
.log-chip:has(input:checked) { background:var(--admin-accent-soft,#eef0fb); border-color:#c9c6ee; color:#5b4fc4; font-weight:600; }
|
||||
@media (max-width:600px) {
|
||||
.analytics-log-head { gap:8px; margin-bottom:8px; }
|
||||
.log-chip { padding:5px 12px; font-size:.82rem; }
|
||||
}
|
||||
.analytics-log { height:460px; overflow-y:auto; overflow-x:hidden; border:1px solid #f0f0f0; border-radius:8px; }
|
||||
.an-log-row { display:grid; grid-template-columns:40px 1fr; gap:10px; align-items:start; padding:11px 12px; border-bottom:1px solid #f2f2f4; font-size:.85rem; }
|
||||
.an-log-row:last-child { border-bottom:none; }
|
||||
.an-log-figure { width:40px; height:40px; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:1.15rem; background:var(--admin-accent-soft,#eef0fb); overflow:hidden; }
|
||||
.an-log-figure img { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
.an-log-main { min-width:0; }
|
||||
.an-log-head { display:flex; flex-wrap:wrap; gap:2px 8px; align-items:baseline; }
|
||||
.an-log-event { font-weight:650; }
|
||||
.an-log-client { color:#7c6ed4; font-weight:600; text-decoration:none; }
|
||||
.an-log-client:hover { text-decoration:underline; }
|
||||
.an-log-num { color:#7c6ed4; font-weight:600; text-decoration:none; }
|
||||
a.an-log-num:hover { text-decoration:underline; }
|
||||
.an-log-time { color:#9a9aa6; font-size:.75rem; white-space:nowrap; margin-left:auto; }
|
||||
.an-log-meta { color:#8a8a95; font-size:.76rem; margin-top:4px; display:flex; flex-direction:column; gap:2px; }
|
||||
.an-log-meta .an-log-line { display:flex; flex-wrap:wrap; gap:4px 12px; }
|
||||
.an-log-meta code { background:rgba(0,0,0,.045); padding:1px 5px; border-radius:4px; word-break:break-all; overflow-wrap:anywhere; max-width:100%; }
|
||||
.an-log-ref { overflow-wrap:anywhere; word-break:break-word; }
|
||||
.an-log-status { text-align:center; color:#aaa; padding:16px; font-size:.85rem; }
|
||||
@media (max-width:600px) {
|
||||
.an-log-row { grid-template-columns:34px 1fr; gap:9px; padding:10px; }
|
||||
.an-log-figure { width:34px; height:34px; font-size:1rem; }
|
||||
.an-log-time { flex-basis:100%; margin-left:0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="/static/chart.min.js?v={{ t.app_version() }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var controls = document.querySelector('.analytics-controls');
|
||||
var L = JSON.parse(controls.getAttribute('data-labels'));
|
||||
|
||||
var META = [
|
||||
{ key: 'landing_views', color: '#7c6ed4', hidden: true },
|
||||
{ key: 'landing_unique', color: '#00b496' },
|
||||
{ key: 'portal_opens', color: '#ff8c26' },
|
||||
{ key: 'media_views', color: '#ff5287' }
|
||||
];
|
||||
|
||||
var fromInput = document.getElementById('anFrom');
|
||||
var toInput = document.getElementById('anTo');
|
||||
var granSelect = document.getElementById('anGran');
|
||||
var kpisEl = document.getElementById('anKpis');
|
||||
var chart = null;
|
||||
|
||||
// Draws a centered status message ("loading", "no data", error) directly on
|
||||
// the chart canvas, so state changes never shift the page layout.
|
||||
var messageOverlay = {
|
||||
id: 'messageOverlay',
|
||||
afterDraw: function (c) {
|
||||
var msg = c.$message;
|
||||
if (!msg) { return; }
|
||||
var ctx = c.ctx;
|
||||
var a = c.chartArea;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.72)';
|
||||
ctx.fillRect(a.left, a.top, a.right - a.left, a.bottom - a.top);
|
||||
ctx.fillStyle = '#8a8a8a';
|
||||
ctx.font = '600 14px system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(msg, (a.left + a.right) / 2, (a.top + a.bottom) / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
function setMessage(msg) {
|
||||
if (!chart) { return; }
|
||||
chart.$message = msg;
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
function fmt(d) {
|
||||
return d.getFullYear() + '-' +
|
||||
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(d.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
function setRange(days) {
|
||||
var to = new Date();
|
||||
var from = new Date();
|
||||
from.setDate(from.getDate() - (days - 1));
|
||||
fromInput.value = fmt(from);
|
||||
toInput.value = fmt(to);
|
||||
}
|
||||
|
||||
function metricLabel(key) { return L[key] || key; }
|
||||
|
||||
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
// Turns an ISO bucket label into a short display date:
|
||||
// "2026-04-11" -> "11 Apr", "2026-04" -> "Apr 2026".
|
||||
function formatLabel(raw) {
|
||||
if (!raw) { return raw; }
|
||||
var parts = String(raw).split('-');
|
||||
if (parts.length === 3) {
|
||||
return parseInt(parts[2], 10) + ' ' + MONTHS[parseInt(parts[1], 10) - 1];
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return MONTHS[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function hexToRgba(hex, a) {
|
||||
var n = parseInt(hex.slice(1), 16);
|
||||
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
|
||||
}
|
||||
|
||||
function renderKpis(report) {
|
||||
kpisEl.replaceChildren();
|
||||
META.forEach(function (m) {
|
||||
var metric = report.metrics.find(function (x) { return x.key === m.key; });
|
||||
var total = metric ? metric.total : 0;
|
||||
var card = document.createElement('div');
|
||||
card.className = 'analytics-kpi';
|
||||
card.style.setProperty('--kpi-color', m.color);
|
||||
var v = document.createElement('div');
|
||||
v.className = 'kpi-value';
|
||||
v.textContent = total.toLocaleString();
|
||||
var lbl = document.createElement('div');
|
||||
lbl.className = 'kpi-label';
|
||||
lbl.textContent = metricLabel(m.key);
|
||||
card.appendChild(v);
|
||||
card.appendChild(lbl);
|
||||
kpisEl.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
var ctx = document.getElementById('anChart').getContext('2d');
|
||||
var datasets = META.map(function (m) {
|
||||
return {
|
||||
label: metricLabel(m.key),
|
||||
data: [],
|
||||
borderColor: m.color,
|
||||
backgroundColor: hexToRgba(m.color, 0.12),
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
tension: 0.25,
|
||||
fill: true,
|
||||
hidden: !!m.hidden,
|
||||
_key: m.key
|
||||
};
|
||||
});
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'rectRounded',
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 12,
|
||||
font: { size: 12 }
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function (items) {
|
||||
return items.length ? formatLabel(items[0].label) : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
maxRotation: 0,
|
||||
callback: function (value) {
|
||||
return formatLabel(this.getLabelForValue(value));
|
||||
}
|
||||
}
|
||||
},
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||
}
|
||||
},
|
||||
plugins: [messageOverlay]
|
||||
});
|
||||
}
|
||||
|
||||
function applyData(report) {
|
||||
chart.data.labels = report.labels;
|
||||
chart.data.datasets.forEach(function (ds) {
|
||||
var metric = report.metrics.find(function (x) { return x.key === ds._key; });
|
||||
ds.data = metric ? metric.values : [];
|
||||
});
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
function renderTop(tableId, rows) {
|
||||
var tbody = document.querySelector('#' + tableId + ' tbody');
|
||||
tbody.replaceChildren();
|
||||
if (!rows || rows.length === 0) {
|
||||
var tr = document.createElement('tr');
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.className = 'analytics-top-empty';
|
||||
td.textContent = L.no_data;
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
rows.forEach(function (row) {
|
||||
var tr = document.createElement('tr');
|
||||
var name = document.createElement('td');
|
||||
name.textContent = row.label;
|
||||
var cnt = document.createElement('td');
|
||||
cnt.textContent = row.count.toLocaleString();
|
||||
tr.appendChild(name);
|
||||
tr.appendChild(cnt);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function mediaLabel(m) {
|
||||
if (!m.exists) { return '#' + m.media_id; }
|
||||
var base = m.client_name || '';
|
||||
if (m.caption) { return base ? base + ': ' + m.caption : m.caption; }
|
||||
return base ? base + ' #' + m.media_id : '#' + m.media_id;
|
||||
}
|
||||
|
||||
// Builds a clickable preview (opens the shared lightbox) for a media ref,
|
||||
// or a placeholder if the file was deleted.
|
||||
function mediaLink(m, thumbClass) {
|
||||
if (m && m.exists && m.url) {
|
||||
var a = document.createElement('a');
|
||||
a.href = m.url;
|
||||
a.setAttribute('data-lightbox', m.file_type === 'video' ? 'video' : 'photo');
|
||||
a.className = 'an-media-link';
|
||||
var img = document.createElement('img');
|
||||
img.className = thumbClass;
|
||||
img.src = m.thumbnail_url;
|
||||
img.alt = '';
|
||||
img.loading = 'lazy';
|
||||
a.appendChild(img);
|
||||
var span = document.createElement('span');
|
||||
span.textContent = mediaLabel(m);
|
||||
a.appendChild(span);
|
||||
return a;
|
||||
}
|
||||
var wrap = document.createElement('span');
|
||||
wrap.className = 'an-media-link';
|
||||
var ph = document.createElement('span');
|
||||
ph.className = 'an-media-missing';
|
||||
ph.textContent = '🗑';
|
||||
wrap.appendChild(ph);
|
||||
var lbl = document.createElement('span');
|
||||
lbl.textContent = mediaLabel(m);
|
||||
wrap.appendChild(lbl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderTopMedia(rows) {
|
||||
var tbody = document.querySelector('#anTopMedia tbody');
|
||||
tbody.replaceChildren();
|
||||
if (!rows || rows.length === 0) {
|
||||
var tr = document.createElement('tr');
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.className = 'analytics-top-empty';
|
||||
td.textContent = L.no_data;
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
rows.forEach(function (m) {
|
||||
var tr = document.createElement('tr');
|
||||
var td1 = document.createElement('td');
|
||||
td1.appendChild(mediaLink(m, 'an-media-thumb'));
|
||||
var td2 = document.createElement('td');
|
||||
td2.textContent = (m.count || 0).toLocaleString();
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function hasData(report) {
|
||||
return report.metrics.some(function (m) { return m.total > 0; });
|
||||
}
|
||||
|
||||
function load() {
|
||||
var params = new URLSearchParams({
|
||||
from: fromInput.value,
|
||||
to: toInput.value,
|
||||
granularity: granSelect.value
|
||||
});
|
||||
setMessage(L.loading);
|
||||
fetch('/admin/analytics/data?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (report) {
|
||||
renderKpis(report);
|
||||
applyData(report);
|
||||
renderTop('anTopClients', report.top_clients);
|
||||
renderTopMedia(report.top_media);
|
||||
setMessage(hasData(report) ? '' : L.no_data);
|
||||
})
|
||||
.catch(function () { setMessage(L.error); });
|
||||
}
|
||||
|
||||
document.querySelectorAll('.preset-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
setRange(parseInt(btn.getAttribute('data-days'), 10));
|
||||
load();
|
||||
});
|
||||
});
|
||||
fromInput.addEventListener('change', load);
|
||||
toInput.addEventListener('change', load);
|
||||
granSelect.addEventListener('change', load);
|
||||
|
||||
// --- Event log (newest first, infinite scroll of older events) ---------
|
||||
var logEl = document.getElementById('anLog');
|
||||
var logCursor = null;
|
||||
var logLoading = false;
|
||||
var logDone = false;
|
||||
|
||||
function logStatus(text) {
|
||||
var s = document.createElement('div');
|
||||
s.className = 'an-log-status';
|
||||
s.textContent = text;
|
||||
return s;
|
||||
}
|
||||
|
||||
function eventIcon(type) {
|
||||
if (type === 'landing_view') { return '🌐'; }
|
||||
if (type === 'portal_open') { return '👤'; }
|
||||
if (type === 'media_view') { return '🖼️'; }
|
||||
return '•';
|
||||
}
|
||||
|
||||
function shortUa(ua) {
|
||||
if (!ua) { return ''; }
|
||||
var browser = '';
|
||||
if (/Edg\//.test(ua)) { browser = 'Edge'; }
|
||||
else if (/OPR\/|Opera/.test(ua)) { browser = 'Opera'; }
|
||||
else if (/Chrome\//.test(ua)) { browser = 'Chrome'; }
|
||||
else if (/Firefox\//.test(ua)) { browser = 'Firefox'; }
|
||||
else if (/Safari\//.test(ua)) { browser = 'Safari'; }
|
||||
var os = '';
|
||||
if (/Windows/.test(ua)) { os = 'Windows'; }
|
||||
else if (/iPhone|iPad|iPod/.test(ua)) { os = 'iOS'; }
|
||||
else if (/Android/.test(ua)) { os = 'Android'; }
|
||||
else if (/Mac OS X|Macintosh/.test(ua)) { os = 'macOS'; }
|
||||
else if (/Linux/.test(ua)) { os = 'Linux'; }
|
||||
return [browser, os].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function metaSpan(text) {
|
||||
var s = document.createElement('span');
|
||||
s.textContent = text;
|
||||
return s;
|
||||
}
|
||||
|
||||
function metaLine() {
|
||||
var line = document.createElement('div');
|
||||
line.className = 'an-log-line';
|
||||
return line;
|
||||
}
|
||||
|
||||
function renderLogRow(e) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'an-log-row';
|
||||
|
||||
// Single leading figure: media preview (clickable) or a type emoji.
|
||||
var figure;
|
||||
if (e.media && e.media.exists && e.media.url && e.media.thumbnail_url) {
|
||||
figure = document.createElement('a');
|
||||
figure.href = e.media.url;
|
||||
figure.setAttribute('data-lightbox', e.media.file_type === 'video' ? 'video' : 'photo');
|
||||
var img = document.createElement('img');
|
||||
img.src = e.media.thumbnail_url;
|
||||
img.alt = '';
|
||||
img.loading = 'lazy';
|
||||
figure.appendChild(img);
|
||||
} else {
|
||||
figure = document.createElement('div');
|
||||
figure.textContent = eventIcon(e.event_type);
|
||||
}
|
||||
figure.className = 'an-log-figure';
|
||||
row.appendChild(figure);
|
||||
|
||||
var main = document.createElement('div');
|
||||
main.className = 'an-log-main';
|
||||
|
||||
var head = document.createElement('div');
|
||||
head.className = 'an-log-head';
|
||||
|
||||
var ev = document.createElement('span');
|
||||
ev.className = 'an-log-event';
|
||||
if (e.event_type === 'media_view') {
|
||||
ev.textContent = L.media_prefix;
|
||||
head.appendChild(ev);
|
||||
if (e.media && e.media.media_id != null) {
|
||||
var num = document.createElement(e.client_id != null ? 'a' : 'span');
|
||||
num.className = 'an-log-num';
|
||||
num.textContent = '№' + e.media.media_id;
|
||||
if (e.client_id != null) { num.href = '/admin/media?client_id=' + e.client_id; }
|
||||
head.appendChild(num);
|
||||
}
|
||||
} else {
|
||||
ev.textContent = L[e.event_type] || e.event_type;
|
||||
head.appendChild(ev);
|
||||
}
|
||||
|
||||
if (e.client_name) {
|
||||
var cl;
|
||||
if (e.client_id != null) {
|
||||
cl = document.createElement('a');
|
||||
cl.href = '/admin/clients/' + e.client_id + '/edit';
|
||||
} else {
|
||||
cl = document.createElement('span');
|
||||
}
|
||||
cl.className = 'an-log-client';
|
||||
cl.textContent = e.client_name;
|
||||
head.appendChild(cl);
|
||||
}
|
||||
|
||||
var tm = document.createElement('span');
|
||||
tm.className = 'an-log-time';
|
||||
tm.textContent = e.created_at;
|
||||
head.appendChild(tm);
|
||||
main.appendChild(head);
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'an-log-meta';
|
||||
|
||||
var line1 = metaLine();
|
||||
if (e.visitor) { line1.appendChild(metaSpan(L.visitor + ': ' + e.visitor)); }
|
||||
var ua = shortUa(e.user_agent);
|
||||
if (ua) { line1.appendChild(metaSpan(ua)); }
|
||||
if (line1.childNodes.length) { meta.appendChild(line1); }
|
||||
|
||||
if (e.referer) {
|
||||
var line3 = metaLine();
|
||||
var ref = document.createElement('span');
|
||||
ref.className = 'an-log-ref';
|
||||
ref.textContent = '← ' + e.referer;
|
||||
line3.appendChild(ref);
|
||||
meta.appendChild(line3);
|
||||
}
|
||||
|
||||
if (meta.childNodes.length) { main.appendChild(meta); }
|
||||
|
||||
row.appendChild(main);
|
||||
return row;
|
||||
}
|
||||
|
||||
function selectedLogTypes() {
|
||||
var types = [];
|
||||
document.querySelectorAll('.log-type-toggle').forEach(function (cb) {
|
||||
if (cb.checked) { types.push(cb.getAttribute('data-type')); }
|
||||
});
|
||||
return types;
|
||||
}
|
||||
|
||||
function resetLog() {
|
||||
logEl.replaceChildren();
|
||||
logCursor = null;
|
||||
logLoading = false;
|
||||
logDone = false;
|
||||
loadLog();
|
||||
}
|
||||
|
||||
function loadLog() {
|
||||
if (logLoading || logDone) { return; }
|
||||
var types = selectedLogTypes();
|
||||
if (types.length === 0) {
|
||||
logEl.replaceChildren(logStatus(L.log_empty));
|
||||
logDone = true;
|
||||
return;
|
||||
}
|
||||
logLoading = true;
|
||||
var busy = logStatus(L.loading);
|
||||
logEl.appendChild(busy);
|
||||
var params = new URLSearchParams({ limit: '40' });
|
||||
if (logCursor) { params.set('before', logCursor); }
|
||||
types.forEach(function (t) { params.append('types', t); });
|
||||
fetch('/admin/analytics/events?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
busy.remove();
|
||||
(data.events || []).forEach(function (e) { logEl.appendChild(renderLogRow(e)); });
|
||||
logCursor = data.next_cursor;
|
||||
logLoading = false;
|
||||
if (!logEl.querySelector('.an-log-row')) {
|
||||
logEl.appendChild(logStatus(L.log_empty));
|
||||
logDone = true;
|
||||
return;
|
||||
}
|
||||
if (!logCursor) {
|
||||
logDone = true;
|
||||
logEl.appendChild(logStatus(L.log_end));
|
||||
return;
|
||||
}
|
||||
// Keep loading until the scroll container is actually scrollable.
|
||||
if (logEl.scrollHeight <= logEl.clientHeight + 20) { loadLog(); }
|
||||
})
|
||||
.catch(function () {
|
||||
busy.remove();
|
||||
logLoading = false;
|
||||
logEl.appendChild(logStatus(L.error));
|
||||
});
|
||||
}
|
||||
|
||||
logEl.addEventListener('scroll', function () {
|
||||
if (logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 40) { loadLog(); }
|
||||
});
|
||||
|
||||
document.querySelectorAll('.log-type-toggle').forEach(function (cb) {
|
||||
cb.addEventListener('change', resetLog);
|
||||
});
|
||||
|
||||
initChart();
|
||||
setRange(30);
|
||||
load();
|
||||
loadLog();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -5,12 +5,17 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ title }}</h1>
|
||||
<h1>{{ t.clients_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<form method="post" action="{{ action_url }}">
|
||||
<div class="field">
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">👤</span>
|
||||
<div><h2>{{ title }}</h2></div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<form method="post" action="{{ action_url }}" class="form-grid">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.clients_name }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="name" value="{{ client_name }}" required>
|
||||
@@ -28,50 +33,52 @@
|
||||
<input class="input" type="email" name="email" value="{{ client_email }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.clients_address }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="address" value="{{ client_address }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.clients_notes }}</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" name="notes" rows="3">{{ client_notes }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.clients_color }}</label>
|
||||
<div class="control" style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="color" name="color" value="{{ client_color }}" style="width:3rem;height:2.2rem;padding:0;border:1px solid #ddd;border-radius:6px;cursor:pointer;">
|
||||
<div class="control color-control">
|
||||
<input type="color" name="color" value="{{ client_color }}">
|
||||
<span class="has-text-grey is-size-7">{{ client_color }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field admin-field-wide">
|
||||
<button type="submit" class="button is-primary is-fullwidth">{{ submit_label }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if is_edit %}
|
||||
<hr>
|
||||
<div class="portal-panel">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.portal_link }}</label>
|
||||
<div class="control" style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input class="input is-small" type="text" readonly id="portalUrl" value="" style="flex:1;">
|
||||
<div class="control inline-control">
|
||||
<input class="input is-small" type="text" readonly id="portalUrl" value="">
|
||||
<button type="button" class="button is-small is-info is-outlined" onclick="navigator.clipboard.writeText(document.getElementById('portalUrl').value)">📋</button>
|
||||
</div>
|
||||
<div style="margin-top:0.75rem;text-align:center;">
|
||||
<canvas id="qrCanvas" style="max-width:180px;"></canvas>
|
||||
<div class="portal-qr">
|
||||
<canvas id="qrCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
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;
|
||||
new QRious({ element: document.getElementById('qrCanvas'), value: url, size: 180, level: 'M' });
|
||||
})();
|
||||
</script>
|
||||
<hr>
|
||||
<div class="danger-zone">
|
||||
{% if client_status == "active" %}
|
||||
<form method="post" action="/admin/clients/{{ client_id }}/archive">
|
||||
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
|
||||
@@ -82,10 +89,12 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if client_status != "deleted" %}
|
||||
<form method="post" action="/admin/clients/{{ client_id }}/delete" onsubmit="return confirm('{{ t.clients_delete_confirm }}');" style="margin-top:0.75rem;">
|
||||
<form method="post" action="/admin/clients/{{ client_id }}/delete" onsubmit="return confirm('{{ t.clients_delete_confirm }}');" class="mt-3">
|
||||
<button type="submit" class="button is-danger is-outlined is-fullwidth">{{ t.clients_delete }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.clients_title }}</h1>
|
||||
<div>
|
||||
<div class="page-head-actions">
|
||||
{% if show_all %}
|
||||
<a href="/admin/clients?lang={{ lang.code() }}" class="button is-small is-light">{{ t.filter_show_active }}</a>
|
||||
{% else %}
|
||||
@@ -17,19 +17,19 @@
|
||||
</div>
|
||||
|
||||
{% if clients.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.clients_empty }}</p>
|
||||
<p class="empty-state">{{ t.clients_empty }}</p>
|
||||
{% else %}
|
||||
{% for client in &clients %}
|
||||
<div class="item-card">
|
||||
<div class="item-card-header">
|
||||
<a href="/admin/clients/{{ client.id }}/edit?lang={{ lang.code() }}" class="name" style="text-decoration:none;color:inherit;">
|
||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:{{ client.color.as_deref().unwrap_or("#7c6ed4") }};margin-right:6px;vertical-align:middle;"></span>{{ client.name }}
|
||||
<a href="/admin/clients/{{ client.id }}/edit?lang={{ lang.code() }}" class="name card-link">
|
||||
<span class="color-dot" style="background:{{ client.color.as_deref().unwrap_or("#7c6ed4") }}"></span>{{ client.name }}
|
||||
</a>
|
||||
<span class="badge badge-{{ client.status }}">{{ t.client_status(&client.status) }}</span>
|
||||
</div>
|
||||
<div class="item-card-meta">
|
||||
{% if let Some(phone) = client.phone.as_deref() %}
|
||||
<span><a href="tel:{{ phone }}" style="color:inherit;text-decoration:none;">📞 {{ phone }}</a></span>
|
||||
<span><a href="tel:{{ phone }}" class="card-link">📞 {{ phone }}</a></span>
|
||||
{% endif %}
|
||||
{% if let Some(email) = client.email.as_deref() %}
|
||||
<span>✉️ {{ email }}</span>
|
||||
|
||||
@@ -10,27 +10,27 @@
|
||||
</div>
|
||||
|
||||
{% if today_visits.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.dashboard_no_visits }}</p>
|
||||
<p class="empty-state">{{ t.dashboard_no_visits }}</p>
|
||||
{% else %}
|
||||
{% for tv in &today_visits %}
|
||||
<div class="item-card">
|
||||
<div class="item-card-header">
|
||||
<a href="/admin/schedule/{{ tv.visit.id }}/edit?lang={{ lang.code() }}" class="name" style="text-decoration:none;color:inherit;">
|
||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:{{ tv.client_color }};margin-right:6px;vertical-align:middle;"></span>{{ tv.client_name }}
|
||||
<a href="/admin/schedule/{{ tv.visit.id }}/edit?lang={{ lang.code() }}" class="name card-link">
|
||||
<span class="color-dot" style="background:{{ tv.client_color }}"></span>{{ tv.client_name }}
|
||||
</a>
|
||||
<span class="badge badge-visit-{{ tv.visit.status }}">{{ t.visit_status(&tv.visit.status) }}</span>
|
||||
</div>
|
||||
<div class="item-card-meta">
|
||||
<span>🕐 {{ tv.visit.time_start }} — {{ tv.visit.time_end }}</span>
|
||||
{% if !tv.client_phone.is_empty() %}
|
||||
<span><a href="tel:{{ tv.client_phone }}" style="color:inherit;text-decoration:none;">📞 {{ tv.client_phone }}</a></span>
|
||||
<span><a href="tel:{{ tv.client_phone }}" class="card-link">📞 {{ tv.client_phone }}</a></span>
|
||||
{% endif %}
|
||||
{% if !tv.client_address.is_empty() %}
|
||||
<span>📍 {{ tv.client_address }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(notes) = tv.visit.notes.as_deref() %}
|
||||
<div style="color:#888;font-size:0.82rem;margin-top:0.3rem;">{{ notes }}</div>
|
||||
<div class="card-note">{{ notes }}</div>
|
||||
{% endif %}
|
||||
<div class="item-card-actions">
|
||||
{% if tv.visit.status == "scheduled" %}
|
||||
@@ -48,22 +48,22 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Feedbacks -->
|
||||
<h2 style="font-size:1.15rem;font-weight:700;margin:1.5rem 0 0.75rem;">{{ t.dashboard_recent_feedbacks }}</h2>
|
||||
<h2 class="section-title">{{ t.dashboard_recent_feedbacks }}</h2>
|
||||
{% if feedbacks.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.dashboard_no_feedbacks }}</p>
|
||||
<p class="empty-state">{{ t.dashboard_no_feedbacks }}</p>
|
||||
{% else %}
|
||||
{% for fb in &feedbacks %}
|
||||
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" class="item-card" style="border-left:3px solid #7c6cff;display:block;text-decoration:none;color:inherit;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;">
|
||||
<strong style="font-size:0.9rem;">{{ fb.client_name }}</strong>
|
||||
<span style="color:#999;font-size:0.8rem;">{{ fb.visit_date }}</span>
|
||||
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" class="item-card feedback-card">
|
||||
<div class="feedback-head">
|
||||
<strong>{{ fb.client_name }}</strong>
|
||||
<span class="feedback-date">{{ fb.visit_date }}</span>
|
||||
</div>
|
||||
<div style="font-size:0.85rem;color:#4a4570;">{{ fb.feedback }}</div>
|
||||
<div class="feedback-text">{{ fb.feedback }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if feedback_total_pages > 1 %}
|
||||
<div style="display:flex;justify-content:center;gap:0.5rem;margin-top:1rem;">
|
||||
<div class="pagination-row">
|
||||
{% if feedback_page > 1 %}
|
||||
<a href="/admin/?lang={{ lang.code() }}&page={{ feedback_page - 1 }}" class="button is-small is-light">«</a>
|
||||
{% endif %}
|
||||
|
||||
+7
-109
@@ -6,119 +6,13 @@
|
||||
<title>{{ t.nav_title }} — {% block title %}{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
<style>
|
||||
:root { --accent: #6c63ff; color-scheme: light; }
|
||||
body { padding-bottom: 4rem; min-height: 100vh; background: #f5f5f5; color: #333; }
|
||||
|
||||
/* ── Top bar ── */
|
||||
.top-header {
|
||||
background: #fff; border-bottom: 1px solid #e8e8e8;
|
||||
padding: 0.5rem 1rem; display: flex; align-items: center;
|
||||
justify-content: space-between; position: sticky; top: 0; z-index: 30;
|
||||
}
|
||||
.top-header .brand { font-weight: 700; font-size: 1.1rem; color: #333; text-decoration: none; }
|
||||
.top-header-right { display: flex; align-items: center; gap: 0.75rem; font-size: 0.85rem; }
|
||||
.top-header-right a { color: #888; text-decoration: none; }
|
||||
.top-header-right .admin-name { color: #aaa; }
|
||||
|
||||
/* ── Bottom tabs (mobile nav) ── */
|
||||
.bottom-tabs {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
|
||||
background: #fff; border-top: 1px solid #e8e8e8;
|
||||
display: flex; height: 3.5rem;
|
||||
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.bottom-tabs::-webkit-scrollbar { display: none; }
|
||||
.bottom-tabs a {
|
||||
flex: 0 0 auto; min-width: 3.2rem; padding: 0 0.45rem;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; text-decoration: none; color: #999;
|
||||
font-size: 0.6rem; font-weight: 600; gap: 0.1rem;
|
||||
transition: color 0.15s; white-space: nowrap;
|
||||
}
|
||||
.bottom-tabs a .tab-icon { font-size: 1.25rem; line-height: 1; }
|
||||
.bottom-tabs a .tab-label { display: block; }
|
||||
.bottom-tabs a.is-active { color: var(--accent); }
|
||||
@media (max-width: 400px) {
|
||||
.bottom-tabs a .tab-label { display: none; }
|
||||
.bottom-tabs a { min-width: 2.8rem; padding: 0 0.3rem; }
|
||||
}
|
||||
|
||||
/* ── Desktop: hide bottom tabs, show top nav ── */
|
||||
.desktop-nav { display: none; }
|
||||
@media (min-width: 769px) {
|
||||
body { padding-bottom: 0; }
|
||||
.bottom-tabs { display: none; }
|
||||
.desktop-nav { display: flex; gap: 0.25rem; }
|
||||
.desktop-nav a {
|
||||
padding: 0.3rem 0.75rem; border-radius: 6px; font-size: 0.9rem;
|
||||
color: #555; text-decoration: none; transition: background 0.15s;
|
||||
}
|
||||
.desktop-nav a:hover { background: #f0f0f0; }
|
||||
.desktop-nav a.is-active { background: var(--accent); color: #fff; }
|
||||
}
|
||||
|
||||
/* ── Content ── */
|
||||
.main-content { padding: 1rem; max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* ── Status badges ── */
|
||||
.badge { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 99px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-new { background: #dbeafe; color: #1e40af; }
|
||||
.badge-in_progress { background: #fef3c7; color: #92400e; }
|
||||
.badge-converted { background: #d1fae5; color: #065f46; }
|
||||
.badge-rejected { background: #fee2e2; color: #991b1b; }
|
||||
.badge-active { background: #d1fae5; color: #065f46; }
|
||||
.badge-archived { background: #e5e7eb; color: #374151; }
|
||||
.badge-visit-scheduled { background: #dbeafe; color: #1e40af; }
|
||||
.badge-visit-completed { background: #d1fae5; color: #065f46; }
|
||||
.badge-visit-cancelled { background: #e5e7eb; color: #374151; }
|
||||
|
||||
/* ── Item cards ── */
|
||||
.item-card {
|
||||
background: #fff; border-radius: 10px; padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.6rem; border: 1px solid #eee;
|
||||
}
|
||||
.item-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; }
|
||||
.item-card-header .name { font-weight: 700; font-size: 1rem; }
|
||||
.item-card-meta { color: #888; font-size: 0.82rem; line-height: 1.5; }
|
||||
.item-card-meta span { margin-right: 1rem; }
|
||||
.item-card-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||
.item-card-actions form { margin: 0; }
|
||||
|
||||
/* ── Small buttons ── */
|
||||
.btn-sm { font-size: 0.78rem !important; padding: 0.25rem 0.6rem !important; height: auto !important; }
|
||||
|
||||
/* ── Page header ── */
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.page-head h1 { font-size: 1.3rem; font-weight: 700; margin: 0; }
|
||||
|
||||
/* ── Forms ── */
|
||||
.form-card { background: #fff; border-radius: 10px; padding: 1.25rem; border: 1px solid #eee; }
|
||||
input, textarea, select,
|
||||
.input, .textarea, .select select {
|
||||
background-color: #fff !important;
|
||||
color: #333 !important;
|
||||
border-color: #dbdbdb !important;
|
||||
}
|
||||
.label, label {
|
||||
color: #363636 !important;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, strong, b, p, span, div, a, td, th, li {
|
||||
color: inherit;
|
||||
}
|
||||
.title, .subtitle, .content, .has-text-dark {
|
||||
color: #363636 !important;
|
||||
}
|
||||
.has-text-grey { color: #7a7a7a !important; }
|
||||
.notification { color: #333 !important; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||
</head>
|
||||
<body>
|
||||
<body class="admin-shell">
|
||||
|
||||
<!-- Top header -->
|
||||
<div class="top-header">
|
||||
<a class="brand" href="/admin/?lang={{ lang.code() }}">🐾 {{ t.nav_title }} <span style="font-size:0.7rem;font-weight:400;color:#aaa;">v{{ t.app_version() }}</span></a>
|
||||
<a class="brand" href="/admin/?lang={{ lang.code() }}">🐾 {{ t.nav_title }} <span class="brand-version">v{{ t.app_version() }}</span></a>
|
||||
<nav class="desktop-nav">
|
||||
<a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}>{{ t.dashboard_title }}</a>
|
||||
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>{{ t.nav_leads }}</a>
|
||||
@@ -127,6 +21,7 @@
|
||||
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>{{ t.nav_media }}</a>
|
||||
<a href="/admin/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/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>
|
||||
</nav>
|
||||
<div class="top-header-right">
|
||||
@@ -164,6 +59,9 @@
|
||||
<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>
|
||||
</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 %}>
|
||||
<span class="tab-icon">⚙️</span><span class="tab-label">{{ t.nav_settings }}</span>
|
||||
</a>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
|
||||
{% if leads.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.leads_empty }}</p>
|
||||
<p class="empty-state">{{ t.leads_empty }}</p>
|
||||
{% else %}
|
||||
{% for lead in &leads %}
|
||||
<div class="item-card">
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div class="item-card-meta">
|
||||
{% if let Some(phone) = lead.phone.as_deref() %}
|
||||
<span><a href="tel:{{ phone }}" style="color:inherit;text-decoration:none;">📞 {{ phone }}</a></span>
|
||||
<span><a href="tel:{{ phone }}" class="card-link">📞 {{ phone }}</a></span>
|
||||
{% endif %}
|
||||
{% if let Some(comment) = lead.comment.as_deref() %}
|
||||
<span>💬 {{ comment }}</span>
|
||||
|
||||
+10
-20
@@ -6,31 +6,21 @@
|
||||
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
||||
.login-box { width: 100%; max-width: 380px; padding: 0 1rem; }
|
||||
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||
input, textarea, select, .input, .textarea, .select select {
|
||||
background-color: #fff !important; color: #333 !important; border-color: #dbdbdb !important;
|
||||
}
|
||||
.label, label { color: #363636 !important; }
|
||||
.notification { color: #333 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="auth-shell">
|
||||
<div class="login-box">
|
||||
<div class="has-text-right mb-3">
|
||||
<a href="?lang={{ lang.other().code() }}" class="has-text-grey is-size-7">{{ lang.other().label() }}</a>
|
||||
<div class="auth-language">
|
||||
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="has-text-centered mb-4">
|
||||
<p class="is-size-3">🐾</p>
|
||||
<h1 class="is-size-4 has-text-weight-bold">{{ t.nav_title }}</h1>
|
||||
<p class="has-text-grey">{{ t.login_title }}</p>
|
||||
<div class="auth-head">
|
||||
<span class="auth-icon">🐾</span>
|
||||
<h1>{{ t.nav_title }}</h1>
|
||||
<p>{{ t.login_title }}</p>
|
||||
</div>
|
||||
{% if let Some(err) = error.as_ref() %}
|
||||
<div class="notification is-danger is-light">{{ err }}</div>
|
||||
@@ -39,7 +29,7 @@
|
||||
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
|
||||
{% endif %}
|
||||
{% if auth_password_enabled %}
|
||||
{% if auth_sso_enabled %}<hr style="margin:1rem 0;">{% endif %}
|
||||
{% if auth_sso_enabled %}<hr class="auth-divider">{% endif %}
|
||||
<form method="post" action="/admin/login/submit">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.users_login }}</label>
|
||||
@@ -50,7 +40,7 @@
|
||||
<div class="control"><input class="input" type="password" name="password" required></div>
|
||||
</div>
|
||||
{% if !turnstile_site_key.is_empty() %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.75rem;"></div>
|
||||
<div class="cf-turnstile turnstile-wrap" data-sitekey="{{ turnstile_site_key }}" data-theme="light"></div>
|
||||
{% endif %}
|
||||
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
|
||||
</form>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Client filter -->
|
||||
<div style="margin-bottom:1rem;">
|
||||
<div class="filter-bar">
|
||||
<div class="select is-small">
|
||||
<select onchange="window.location.href='/admin/media?lang={{ lang.code() }}' + (this.value ? '&client_id=' + this.value : '')">
|
||||
<option value="">{{ t.media_all_clients }}</option>
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
|
||||
{% if items.is_empty() %}
|
||||
<p class="has-text-grey">{{ t.media_empty }}</p>
|
||||
<p class="empty-state">{{ t.media_empty }}</p>
|
||||
{% else %}
|
||||
<div class="media-grid">
|
||||
{% for item in &items %}
|
||||
@@ -46,13 +46,13 @@
|
||||
<div class="media-meta">
|
||||
<strong>{{ item.client_name }}</strong>
|
||||
{% if let Some(d) = item.visit_date.as_deref() %}
|
||||
<span style="color:#999;">{{ d }}</span>
|
||||
<span class="feedback-date">{{ d }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(cap) = item.media.caption.as_deref() %}
|
||||
<div style="font-size:0.82rem;color:#666;margin-top:0.2rem;">{{ cap }}</div>
|
||||
<div class="media-caption">{{ cap }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/media/{{ item.media.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
|
||||
<form method="post" action="/admin/media/{{ item.media.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" class="mt-2">
|
||||
<button class="button is-small is-danger is-outlined btn-sm">{{ t.media_delete }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -74,69 +74,4 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.media-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #eee;
|
||||
overflow: hidden;
|
||||
}
|
||||
.media-card img {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.media-card .photo-thumb {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
}
|
||||
.media-card .video-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: #111;
|
||||
}
|
||||
.media-card .video-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 5px #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-info {
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.media-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.media-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.media-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
.media-card img, .media-card .photo-thumb, .media-card .video-thumb {
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -5,14 +5,18 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.media_upload_title }}</h1>
|
||||
<h1>{{ t.media_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<div style="margin-bottom:1rem;font-size:0.9rem;color:#666;">
|
||||
<div><strong>{{ t.schedule_client }}:</strong> {{ client_name }}</div>
|
||||
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
|
||||
</div>
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">📤</span>
|
||||
<div>
|
||||
<h2>{{ t.media_upload_title }}</h2>
|
||||
<p>{{ client_name }} · {{ visit_label }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
|
||||
<form id="uploadForm" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
@@ -20,7 +24,7 @@
|
||||
<div class="control">
|
||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||
</div>
|
||||
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||
<p id="fileCount" class="field-help"></p>
|
||||
<div id="uploadQueue" class="upload-queue"></div>
|
||||
</div>
|
||||
|
||||
@@ -32,13 +36,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
||||
<div id="uploadProgress" class="upload-progress">
|
||||
<div class="upload-progress-head">
|
||||
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||
<span id="uploadPercent">0%</span>
|
||||
</div>
|
||||
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
||||
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
||||
<div class="upload-progress-track">
|
||||
<div id="uploadBar" class="upload-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,18 +163,7 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.upload-queue { display:flex; flex-direction:column; gap:.4rem; margin-top:.65rem; }
|
||||
.upload-queue:empty { display:none; }
|
||||
.upload-queue-item { display:grid; grid-template-columns:34px minmax(0,1fr); gap:.55rem; align-items:center; padding:.45rem .55rem; background:#f7f6ff; border-radius:8px; }
|
||||
.upload-queue-icon { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:#ebe8ff; font-size:1.05rem; }
|
||||
.upload-queue-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.78rem; color:#494467; }
|
||||
.upload-queue-state { font-size:.68rem; color:#8a84a5; }
|
||||
.upload-queue-track { height:3px; overflow:hidden; border-radius:99px; background:#dedbea; margin-top:.25rem; }
|
||||
.upload-queue-bar { height:100%; width:0; background:#7567e8; transition:width .2s; }
|
||||
#uploadBar.is-processing, .upload-queue-bar.is-processing { width:35% !important; animation:upload-processing 1.15s ease-in-out infinite; }
|
||||
@keyframes upload-processing { from { transform:translateX(-110%); } to { transform:translateX(300%); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -9,7 +9,7 @@
|
||||
<a href="/admin/schedule/new?lang={{ lang.code() }}" class="button is-primary is-small">+ {{ t.schedule_new }}</a>
|
||||
</div>
|
||||
|
||||
<div class="form-card" id="calendar-wrap" style="padding:0.5rem;">
|
||||
<div class="form-card" id="calendar-wrap">
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
@@ -18,40 +18,6 @@
|
||||
{% if lang == Lang::Ru %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.17/locales/ru.global.min.js"></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
#calendar-wrap { overflow: hidden; }
|
||||
.fc { font-size: 0.85rem; color: #333; }
|
||||
.fc .fc-daygrid-day-number,
|
||||
.fc .fc-col-header-cell-cushion,
|
||||
.fc .fc-list-day-text,
|
||||
.fc .fc-list-day-side-text { color: #333 !important; }
|
||||
.fc th, .fc td { color: #333; }
|
||||
.fc .fc-toolbar-title { color: #333 !important; }
|
||||
.fc .fc-button { color: #fff !important; }
|
||||
.fc .fc-toolbar { flex-wrap: wrap; gap: 0.3rem; }
|
||||
.fc .fc-toolbar-title { font-size: 1.1rem !important; }
|
||||
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
|
||||
.fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; }
|
||||
.fc-event.ev-completed .fc-event-title,
|
||||
.fc-event.ev-completed .fc-list-event-title { text-decoration: line-through; opacity: 0.75; }
|
||||
.fc-event.ev-cancelled .fc-event-title,
|
||||
.fc-event.ev-cancelled .fc-list-event-title { text-decoration: line-through; }
|
||||
.fc .fc-day-today { background: #eef2ff !important; }
|
||||
.fc .fc-day.day-weekend { background: #faf5f0; }
|
||||
.fc .fc-day-today.day-weekend { background: #eef2ff !important; }
|
||||
@media (max-width: 768px) {
|
||||
.fc .fc-toolbar { font-size: 0.75rem; }
|
||||
.fc .fc-toolbar-title { font-size: 0.95rem !important; }
|
||||
.fc .fc-button { padding: 0.2rem 0.35rem !important; font-size: 0.72rem !important; }
|
||||
}
|
||||
.visit-modal-bg { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.35); z-index:100; align-items:center; justify-content:center; }
|
||||
.visit-modal-bg.is-open { display:flex; }
|
||||
.visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); }
|
||||
.visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; }
|
||||
.visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; }
|
||||
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
||||
</style>
|
||||
|
||||
<div class="visit-modal-bg" id="visitModal">
|
||||
<div class="visit-modal">
|
||||
<h3><span class="color-dot" id="vmDot"></span><span id="vmTitle"></span></h3>
|
||||
@@ -60,10 +26,10 @@
|
||||
<div id="vmAddress"></div>
|
||||
<div id="vmAdmin"></div>
|
||||
<div id="vmTime"></div>
|
||||
<div id="vmNotes" style="margin-top:0.3rem;"></div>
|
||||
<div id="vmNotes" class="mt-2"></div>
|
||||
</div>
|
||||
<div id="vmStatus" style="margin-bottom:1rem;"></div>
|
||||
<a id="vmEditLink" href="#" class="button is-primary is-fullwidth" style="font-size:1rem;font-weight:700;padding:0.65rem;">📋 {{ t.schedule_edit_title }}</a>
|
||||
<div id="vmStatus" class="mb-4"></div>
|
||||
<a id="vmEditLink" href="#" class="button is-primary is-fullwidth">📋 {{ t.schedule_edit_title }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +48,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
timeZone: '{{ timezone }}',
|
||||
initialView: window.innerWidth < 768 ? 'listWeek' : 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
left: 'prev,next,today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||
},
|
||||
@@ -98,7 +64,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const p = ev.extendedProps;
|
||||
document.getElementById('vmDot').style.background = p.client_color || '#7c6ed4';
|
||||
document.getElementById('vmTitle').textContent = p.client_name;
|
||||
document.getElementById('vmClient').innerHTML = p.client_phone ? ('<a href="tel:' + p.client_phone + '" style="color:inherit;text-decoration:none;">📞 ' + p.client_phone + '</a>') : '';
|
||||
document.getElementById('vmClient').innerHTML = p.client_phone ? ('<a href="tel:' + p.client_phone + '" class="card-link">📞 ' + p.client_phone + '</a>') : '';
|
||||
document.getElementById('vmAddress').textContent = p.client_address ? ('📍 ' + p.client_address) : '';
|
||||
document.getElementById('vmAdmin').textContent = '👤 ' + p.admin_name;
|
||||
document.getElementById('vmTime').textContent = '🕐 ' + p.time_start + ' — ' + p.time_end;
|
||||
|
||||
@@ -5,10 +5,18 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.schedule_edit_title }}</h1>
|
||||
<h1>{{ t.schedule_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">📋</span>
|
||||
<div>
|
||||
<h2>{{ t.schedule_edit_title }}</h2>
|
||||
<p>{{ client.name }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<form method="post" action="/admin/schedule/{{ visit.id }}/save">
|
||||
<!-- Client -->
|
||||
<div class="field">
|
||||
@@ -45,7 +53,7 @@
|
||||
<!-- Time -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_default_time }}</label>
|
||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
||||
<div class="columns is-mobile time-columns">
|
||||
<div class="column">
|
||||
<div class="control">
|
||||
<input class="input" type="time" name="time_start" value="{{ visit.time_start }}" required>
|
||||
@@ -93,21 +101,21 @@
|
||||
</div>
|
||||
|
||||
{% if let Some(fb) = visit.client_feedback.as_deref() %}
|
||||
<div style="margin-bottom:1rem;">
|
||||
<div class="mb-4">
|
||||
<label class="label">{{ t.schedule_client_feedback }}</label>
|
||||
<div style="background:#f0f0ff;border-radius:8px;padding:0.6rem 0.85rem;font-size:0.9rem;color:#4a4570;">{{ fb }}</div>
|
||||
<div class="admin-note admin-note-info">{{ fb }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr style="margin:1rem 0;">
|
||||
<hr>
|
||||
|
||||
<!-- Media -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||
<label class="label" style="margin:0;">{{ t.nav_media }}</label>
|
||||
<div class="section-inline-head">
|
||||
<label class="label">{{ t.nav_media }}</label>
|
||||
<button type="button" class="button is-info is-small is-outlined" onclick="document.getElementById('uploadModal').classList.add('is-open')">+ {{ t.media_upload }}</button>
|
||||
</div>
|
||||
{% if media.is_empty() %}
|
||||
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ t.media_empty }}</p>
|
||||
<p class="empty-state mb-4">{{ t.media_empty }}</p>
|
||||
{% else %}
|
||||
<div class="visit-media-grid">
|
||||
{% for m in &media %}
|
||||
@@ -139,7 +147,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr style="margin:1rem 0;">
|
||||
<hr>
|
||||
|
||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
||||
</form>
|
||||
@@ -147,18 +155,19 @@
|
||||
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
|
||||
{% endfor %}
|
||||
|
||||
<hr style="margin:1rem 0;">
|
||||
<hr>
|
||||
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
||||
<button type="submit" class="button is-danger is-outlined is-fullwidth is-small">{{ t.schedule_delete }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="upload-modal-bg" id="uploadModal">
|
||||
<div class="upload-modal">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
||||
<button type="button" id="uploadModalClose" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;">✕</button>
|
||||
<div class="modal-head">
|
||||
<h3>{{ t.media_upload_title }}</h3>
|
||||
<button type="button" id="uploadModalClose" class="modal-close">✕</button>
|
||||
</div>
|
||||
<form id="uploadForm" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
@@ -166,7 +175,7 @@
|
||||
<div class="control">
|
||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||
</div>
|
||||
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||
<p id="fileCount" class="field-help"></p>
|
||||
<div id="uploadQueue" class="upload-queue"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -177,13 +186,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
||||
<div id="uploadProgress" class="upload-progress">
|
||||
<div class="upload-progress-head">
|
||||
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||
<span id="uploadPercent">0%</span>
|
||||
</div>
|
||||
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
||||
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
||||
<div class="upload-progress-track">
|
||||
<div id="uploadBar" class="upload-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -192,126 +201,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-picker {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.status-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.6rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
border: 2px solid transparent;
|
||||
background: #f5f5f5;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: #777;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.status-btn-icon { font-size: 1.3rem; line-height: 1; }
|
||||
.status-btn:hover { filter: brightness(0.95); }
|
||||
|
||||
.status-btn-scheduled.is-active { background: #dbeafe; border-color: #3b82f6; color: #1e40af; }
|
||||
.status-btn-completed.is-active { background: #d1fae5; border-color: #22c55e; color: #15803d; }
|
||||
.status-btn-cancelled.is-active { background: #fee2e2; border-color: #ef4444; color: #b91c1c; }
|
||||
|
||||
.status-btn-scheduled:not(.is-active):hover { background: #eff6ff; color: #3b82f6; }
|
||||
.status-btn-completed:not(.is-active):hover { background: #f0fdf4; color: #22c55e; }
|
||||
.status-btn-cancelled:not(.is-active):hover { background: #fff5f5; color: #ef4444; }
|
||||
|
||||
.visit-media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.visit-media-item {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
overflow: hidden;
|
||||
}
|
||||
.visit-media-item img {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.visit-media-item .photo-thumb {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
}
|
||||
.visit-media-item .video-thumb-sm {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: #111;
|
||||
}
|
||||
.visit-media-item .video-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 4px #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.visit-media-item .media-cap {
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
padding: 0.2rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.visit-media-delete {
|
||||
padding: 0.25rem 0.4rem 0.4rem;
|
||||
}
|
||||
.visit-media-delete .button {
|
||||
width: 100%;
|
||||
font-size: 0.68rem;
|
||||
min-height: 1.65rem;
|
||||
}
|
||||
.upload-modal-bg {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.upload-modal-bg.is-open {
|
||||
display: flex;
|
||||
}
|
||||
.upload-modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
.upload-queue { display:flex; flex-direction:column; gap:.4rem; margin-top:.65rem; }
|
||||
.upload-queue:empty { display:none; }
|
||||
.upload-queue-item { display:grid; grid-template-columns:34px minmax(0,1fr); gap:.55rem; align-items:center; padding:.45rem .55rem; background:#f7f6ff; border-radius:8px; }
|
||||
.upload-queue-icon { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:#ebe8ff; font-size:1.05rem; }
|
||||
.upload-queue-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.78rem; color:#494467; }
|
||||
.upload-queue-state { font-size:.68rem; color:#8a84a5; }
|
||||
.upload-queue-track { height:3px; overflow:hidden; border-radius:99px; background:#dedbea; margin-top:.25rem; }
|
||||
.upload-queue-bar { height:100%; width:0; background:#7567e8; transition:width .2s; }
|
||||
#uploadBar.is-processing, .upload-queue-bar.is-processing { width:35% !important; animation:upload-processing 1.15s ease-in-out infinite; }
|
||||
@keyframes upload-processing { from { transform:translateX(-110%); } to { transform:translateX(300%); } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Status picker
|
||||
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ t.schedule_new_title }}</h1>
|
||||
<h1>{{ t.schedule_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">📅</span>
|
||||
<div><h2>{{ t.schedule_new_title }}</h2></div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<form method="post" action="/admin/schedule/create" id="visitForm">
|
||||
|
||||
<!-- Client -->
|
||||
@@ -94,85 +99,7 @@
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Time row */
|
||||
.time-row { display:flex; align-items:center; gap:0.5rem; }
|
||||
.time-block { display:flex; flex-direction:column; flex:1; }
|
||||
.time-lbl { font-size:0.75rem; color:#888; margin-bottom:0.2rem; }
|
||||
.time-sep { font-size:1.2rem; color:#aaa; padding-top:1.2rem; }
|
||||
|
||||
/* Calendar toolbar */
|
||||
.cal-toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.5rem; }
|
||||
|
||||
|
||||
/* Calendar container */
|
||||
.sched-cal { background:#fafafa; border:1px solid #eee; border-radius:10px; overflow:hidden; }
|
||||
|
||||
.cal-nav { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 0.75rem; background:#fff; border-bottom:1px solid #eee; }
|
||||
.cal-nav button { background:none; border:none; font-size:1.1rem; cursor:pointer; color:#6c63ff; padding:0.2rem 0.5rem; border-radius:4px; }
|
||||
.cal-nav button:hover { background:#f0eeff; }
|
||||
.cal-nav span { font-weight:700; font-size:1rem; color:#333; }
|
||||
|
||||
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
|
||||
|
||||
.cal-wday { text-align:center; font-size:0.72rem; font-weight:700; color:#aaa; padding:0.4rem 0; background:#fafafa; }
|
||||
.cal-wday.is-weekend { color:#f0a0a0; }
|
||||
|
||||
.cal-day { text-align:center; padding:0.55rem 0.2rem; font-size:0.9rem; cursor:pointer; color:#333; border-radius:0; transition:background 0.1s; position:relative; user-select:none; -webkit-user-select:none; }
|
||||
.cal-day:hover { background:#f0eeff; }
|
||||
.cal-day.is-empty { cursor:default; }
|
||||
.cal-day.is-empty:hover { background:none; }
|
||||
.cal-day.is-today { font-weight:700; color:#6c63ff; }
|
||||
.cal-day.is-selected { background:#6c63ff !important; color:#fff !important; border-radius:0; }
|
||||
.cal-day.is-range-start { background:#a89cff !important; color:#fff !important; }
|
||||
.cal-day.is-past { color:#ccc; }
|
||||
|
||||
/* Selected days list */
|
||||
.day-row { display:flex; align-items:center; gap:0.4rem; padding:0.45rem 0; border-bottom:1px solid #f5f5f5; }
|
||||
.day-row:last-child { border-bottom:none; }
|
||||
.day-date { font-weight:600; font-size:0.85rem; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.day-times { display:flex; align-items:center; gap:0.25rem; flex-shrink:0; }
|
||||
.day-times .time-sep { color:#bbb; font-size:0.8rem; }
|
||||
.day-rm { background:none; border:none; color:#ccc; cursor:pointer; font-size:1rem; padding:0.15rem 0.25rem; flex-shrink:0; line-height:1; }
|
||||
.day-rm:hover { color:#e55; }
|
||||
|
||||
/* Time badge */
|
||||
.time-badge-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ede9ff;
|
||||
color: #5b52d6;
|
||||
border: 1.5px solid #c4beff;
|
||||
border-radius: 20px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
min-width: 3.6rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.time-badge-wrap:focus-within {
|
||||
border-color: #6c63ff;
|
||||
background: #f0eeff;
|
||||
}
|
||||
.time-badge-label { pointer-events: none; z-index: 1; }
|
||||
.time-badge-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 16px; /* prevent iOS zoom */
|
||||
}
|
||||
</style>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
|
||||
+118
-146
@@ -4,7 +4,7 @@
|
||||
{% block title %}{{ t.settings_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head settings-page-head">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>{{ t.settings_title }}</h1>
|
||||
<p>{{ t.settings_intro }}</p>
|
||||
@@ -12,48 +12,86 @@
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="notification is-success is-light settings-message">{{ t.settings_saved }}</div>
|
||||
<div class="notification is-success is-light admin-message">{{ t.settings_saved }}</div>
|
||||
{% endif %}
|
||||
{% if let Some(message) = error %}
|
||||
<div class="notification is-danger is-light settings-message">{{ message }}</div>
|
||||
<div class="notification is-danger is-light admin-message">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="settingsForm" class="settings-form" method="post" action="/admin/settings/save">
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">🌐</span>
|
||||
<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">
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">🌐</span>
|
||||
<div>
|
||||
<h2>{{ t.settings_section_general }}</h2>
|
||||
<p>{{ t.settings_section_general_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<div class="admin-section-body">
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="contactInfo">{{ t.settings_contact_info }}</label>
|
||||
<div class="control">
|
||||
<input id="contactInfo" class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="siteDomain">{{ t.settings_site_domain }}</label>
|
||||
<div class="control">
|
||||
<input id="siteDomain" class="input" type="url" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="timezone">{{ t.settings_timezone }}</label>
|
||||
<div class="control">
|
||||
<input id="timezone" class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="pricingInfo">{{ t.settings_pricing_info }}</label>
|
||||
<div class="control">
|
||||
<textarea id="pricingInfo" class="textarea" name="pricing_info" rows="3" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="seoKeywordsInput">{{ t.settings_seo_keywords }}</label>
|
||||
<div class="control">
|
||||
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3" placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
@@ -64,111 +102,111 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">☁️</span>
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">☁️</span>
|
||||
<div>
|
||||
<div class="settings-title-row">
|
||||
<div class="admin-title-row">
|
||||
<h2>{{ t.settings_section_storage }}</h2>
|
||||
<span class="settings-status{% if r2_enabled_checked %} is-enabled{% endif %}">
|
||||
<span class="status-pill{% if r2_enabled_checked %} is-enabled{% endif %}">
|
||||
{% if r2_enabled_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ t.settings_r2_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<label class="settings-toggle" for="r2Enabled">
|
||||
<div class="admin-section-body">
|
||||
<label class="admin-toggle" for="r2Enabled">
|
||||
<input id="r2Enabled" type="checkbox" name="r2_enabled" value="true"{% if r2_enabled_checked %} checked{% endif %}>
|
||||
<span class="toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span>
|
||||
<strong>{{ t.settings_r2_enabled }}</strong>
|
||||
<small>{{ t.settings_r2_help }}</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="settings-note settings-note-info">{{ t.settings_r2_migration_help }}</div>
|
||||
<div class="admin-note admin-note-info">{{ t.settings_r2_migration_help }}</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field">
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="r2AccountId">{{ t.settings_r2_account_id }}</label>
|
||||
<div class="control">
|
||||
<input id="r2AccountId" class="input is-family-monospace" type="text" name="r2_account_id" maxlength="32" autocomplete="off" spellcheck="false" placeholder="0123456789abcdef0123456789abcdef" value="{% for s in &settings %}{% if s.key == "r2_account_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="r2Bucket">{{ t.settings_r2_bucket }}</label>
|
||||
<div class="control">
|
||||
<input id="r2Bucket" class="input is-family-monospace" type="text" name="r2_bucket" autocomplete="off" spellcheck="false" placeholder="pet-media" value="{% for s in &settings %}{% if s.key == "r2_bucket" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="r2AccessKey">{{ t.settings_r2_access_key_id }}</label>
|
||||
<div class="control">
|
||||
<input id="r2AccessKey" class="input is-family-monospace" type="text" name="r2_access_key_id" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "r2_access_key_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="r2Secret">{{ t.settings_r2_secret_access_key }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="r2Secret" class="input is-family-monospace" type="password" name="r2_secret_access_key" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="settings-help">{% if r2_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
<p class="field-help">{% if r2_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if r2_secret_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_r2_secret_access_key" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_r2_secret_access_key" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">🔔</span>
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">🔔</span>
|
||||
<div>
|
||||
<div class="settings-title-row">
|
||||
<div class="admin-title-row">
|
||||
<h2>{{ t.settings_section_notifications }}</h2>
|
||||
<span class="settings-status{% if client_notifications_checked %} is-enabled{% endif %}">
|
||||
<span class="status-pill{% if client_notifications_checked %} is-enabled{% endif %}">
|
||||
{% if client_notifications_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ t.settings_section_notifications_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<label class="settings-toggle" for="clientNotifications">
|
||||
<div class="admin-section-body">
|
||||
<label class="admin-toggle" for="clientNotifications">
|
||||
<input id="clientNotifications" type="checkbox" name="client_notifications_enabled" value="true"{% if client_notifications_checked %} checked{% endif %}>
|
||||
<span class="toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span>
|
||||
<strong>{{ t.settings_client_notifications_enabled }}</strong>
|
||||
<small>{{ t.settings_client_notifications_help }}</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="settings-subsection">
|
||||
<div class="admin-subsection">
|
||||
<h3>Web Push / VAPID</h3>
|
||||
<div class="settings-note settings-note-warning">
|
||||
<div class="admin-note admin-note-warning">
|
||||
<p>{{ t.settings_vapid_warning }}</p>
|
||||
<p>{{ t.settings_vapid_generate }} <code>cargo run --bin generate_vapid</code></p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="vapidPublic">{{ t.settings_vapid_public_key }}</label>
|
||||
<div class="control">
|
||||
<input id="vapidPublic" class="input is-family-monospace" type="text" name="vapid_public_key" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "vapid_public_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="vapidPrivate">{{ t.settings_vapid_private_key }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="vapidPrivate" class="input is-family-monospace" type="password" name="vapid_private_key" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="settings-help">{% if vapid_private_key_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
<p class="field-help">{% if vapid_private_key_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if vapid_private_key_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_vapid_private_key" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_vapid_private_key" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="vapidSubject">{{ t.settings_vapid_subject }}</label>
|
||||
<div class="control">
|
||||
<input id="vapidSubject" class="input" type="text" name="vapid_subject" placeholder="mailto:admin@example.com" value="{% for s in &settings %}{% if s.key == "vapid_subject" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
@@ -176,13 +214,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="settings-details">
|
||||
<details class="admin-details">
|
||||
<summary>{{ t.settings_push_subscribers }} <span>{{ push_subscribers.len() }}</span></summary>
|
||||
<div class="settings-details-body">
|
||||
<div class="admin-details-body">
|
||||
{% if push_subscribers.is_empty() %}
|
||||
<p class="settings-help">{{ t.settings_push_no_subscribers }}</p>
|
||||
<p class="field-help">{{ t.settings_push_no_subscribers }}</p>
|
||||
{% else %}
|
||||
<div class="settings-table-wrap">
|
||||
<div class="table-wrap">
|
||||
<table class="table is-fullwidth is-striped is-narrow">
|
||||
<thead><tr>
|
||||
<th>{{ t.settings_push_client }}</th>
|
||||
@@ -207,98 +245,98 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="settings-subsection">
|
||||
<div class="admin-subsection">
|
||||
<h3>Telegram</h3>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="telegramToken">{{ t.settings_telegram_bot_token }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="telegramToken" class="input is-family-monospace" type="password" name="telegram_bot_token" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="settings-help">{% if telegram_bot_token_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
<p class="field-help">{% if telegram_bot_token_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if telegram_bot_token_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_telegram_bot_token" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_telegram_bot_token" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">🛡️</span>
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">🛡️</span>
|
||||
<div>
|
||||
<h2>{{ t.settings_section_captcha }}</h2>
|
||||
<p>{{ t.settings_section_captcha_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field">
|
||||
<div class="admin-section-body">
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="turnstileSiteKey">{{ t.settings_turnstile_site_key }}</label>
|
||||
<div class="control">
|
||||
<input id="turnstileSiteKey" class="input is-family-monospace" type="text" name="turnstile_site_key" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="turnstileSecret">{{ t.settings_turnstile_secret_key }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="turnstileSecret" class="input is-family-monospace" type="password" name="turnstile_secret_key" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="settings-help">{% if turnstile_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
<p class="field-help">{% if turnstile_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if turnstile_secret_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_turnstile_secret_key" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_turnstile_secret_key" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header class="settings-section-head">
|
||||
<span class="settings-section-icon" aria-hidden="true">🔐</span>
|
||||
<section class="admin-section">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">🔐</span>
|
||||
<div>
|
||||
<h2>{{ t.settings_section_oidc }}</h2>
|
||||
<p>{{ t.settings_section_oidc_help }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="settings-section-body">
|
||||
<div class="settings-toggle-list">
|
||||
<label class="settings-toggle" for="passwordAuth">
|
||||
<div class="admin-section-body">
|
||||
<div class="admin-toggle-list">
|
||||
<label class="admin-toggle" for="passwordAuth">
|
||||
<input id="passwordAuth" type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
|
||||
<span class="toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span><strong>{{ t.settings_auth_password_enabled }}</strong></span>
|
||||
</label>
|
||||
<label class="settings-toggle" for="ssoAuth">
|
||||
<label class="admin-toggle" for="ssoAuth">
|
||||
<input id="ssoAuth" type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
|
||||
<span class="toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span><strong>{{ t.settings_auth_sso_enabled }}</strong></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<div class="admin-grid">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="oidcIssuer">{{ t.settings_oidc_issuer_url }}</label>
|
||||
<div class="control">
|
||||
<input id="oidcIssuer" class="input" type="url" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="oidcClientId">{{ t.settings_oidc_client_id }}</label>
|
||||
<div class="control">
|
||||
<input id="oidcClientId" class="input is-family-monospace" type="text" name="oidc_client_id" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field settings-field">
|
||||
<div class="field admin-field">
|
||||
<label class="label" for="oidcClientSecret">{{ t.settings_oidc_client_secret }}</label>
|
||||
<div class="control secret-control">
|
||||
<input id="oidcClientSecret" class="input is-family-monospace" type="password" name="oidc_client_secret" autocomplete="new-password" placeholder="••••••••••••••••">
|
||||
</div>
|
||||
<p class="settings-help">{% if oidc_client_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
<p class="field-help">{% if oidc_client_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
|
||||
{% if oidc_client_secret_configured %}
|
||||
<label class="secret-clear"><input type="checkbox" name="clear_oidc_client_secret" value="true"> {{ t.settings_secret_clear }}</label>
|
||||
<label class="admin-check admin-check-danger"><input type="checkbox" name="clear_oidc_client_secret" value="true"><span class="admin-check-box">✓</span><span>{{ t.settings_secret_clear }}</span></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field settings-field settings-field-wide">
|
||||
<div class="field admin-field admin-field-wide">
|
||||
<label class="label" for="oidcGroups">{{ t.settings_oidc_allowed_groups }}</label>
|
||||
<div class="control">
|
||||
<input id="oidcGroups" class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
@@ -308,77 +346,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-save-bar">
|
||||
<div class="sticky-actions">
|
||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.settings-page-head { align-items:flex-end; margin-bottom:1.25rem; }
|
||||
.settings-page-head h1 { font-size:1.55rem; }
|
||||
.settings-page-head p { margin-top:.25rem; color:#777; font-size:.9rem; }
|
||||
.settings-message { border-radius:10px; margin-bottom:1rem; }
|
||||
.settings-form { display:flex; flex-direction:column; gap:1rem; }
|
||||
.settings-section { background:#fff; border:1px solid #e7e5ef; border-radius:14px; overflow:hidden; box-shadow:0 2px 10px rgba(42,34,80,.035); }
|
||||
.settings-section-head { display:grid; grid-template-columns:42px minmax(0,1fr); gap:.8rem; padding:1rem 1.15rem; background:linear-gradient(180deg,#fff,#fbfaff); border-bottom:1px solid #eceaf2; }
|
||||
.settings-section-icon { display:flex; align-items:center; justify-content:center; width:42px; height:42px; border-radius:11px; background:#efedff; font-size:1.2rem; }
|
||||
.settings-section-head h2 { margin:0; font-size:1.05rem; font-weight:750; color:#302c49; }
|
||||
.settings-section-head p { margin:.2rem 0 0; max-width:680px; color:#817c94; font-size:.82rem; line-height:1.45; }
|
||||
.settings-title-row { display:flex; align-items:center; flex-wrap:wrap; gap:.55rem; }
|
||||
.settings-status { display:inline-flex; align-items:center; padding:.12rem .48rem; border-radius:99px; background:#eeedf2; color:#777184; font-size:.67rem; font-weight:700; }
|
||||
.settings-status.is-enabled { background:#dcf7e8; color:#177349; }
|
||||
.settings-section-body { padding:1.15rem; }
|
||||
.settings-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem 1.1rem; }
|
||||
.settings-field { min-width:0; margin:0 !important; }
|
||||
.settings-field-wide { grid-column:1 / -1; }
|
||||
.settings-field .label { margin-bottom:.38rem; font-size:.78rem; font-weight:700; color:#514c66 !important; }
|
||||
.settings-field .input, .settings-field .textarea { border-radius:8px; box-shadow:none; font-size:.88rem; }
|
||||
.settings-field .input:focus, .settings-field .textarea:focus { border-color:#7c6cff !important; box-shadow:0 0 0 2px rgba(124,108,255,.12); }
|
||||
.settings-field .textarea { min-height:76px; resize:vertical; }
|
||||
.settings-help { margin-top:.35rem; color:#8b869b; font-size:.72rem; line-height:1.4; }
|
||||
.secret-control { position:relative; }
|
||||
.secret-control::after { content:"🔒"; position:absolute; right:.75rem; top:50%; transform:translateY(-50%); font-size:.78rem; opacity:.48; pointer-events:none; }
|
||||
.secret-control .input { padding-right:2.25rem; letter-spacing:.05em; }
|
||||
.secret-clear { display:inline-flex; align-items:center; gap:.35rem; margin-top:.38rem; color:#a04e58 !important; font-size:.7rem; cursor:pointer; }
|
||||
.secret-clear input { accent-color:#b85c66; }
|
||||
.settings-toggle-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.7rem; margin-bottom:1rem; }
|
||||
.settings-toggle { display:flex; align-items:flex-start; gap:.7rem; padding:.75rem .85rem; border:1px solid #e8e5f2; border-radius:10px; background:#fbfaff; cursor:pointer; }
|
||||
.settings-toggle > input { position:absolute; opacity:0; pointer-events:none; }
|
||||
.toggle-track { flex:0 0 auto; width:38px; height:22px; padding:2px; border-radius:99px; background:#ccc8d6; transition:.18s; }
|
||||
.toggle-track span { display:block; width:18px; height:18px; border-radius:50%; background:#fff; box-shadow:0 1px 4px rgba(0,0,0,.2); transition:.18s; }
|
||||
.settings-toggle > input:checked + .toggle-track { background:#7567e8; }
|
||||
.settings-toggle > input:checked + .toggle-track span { transform:translateX(16px); }
|
||||
.settings-toggle > input:focus-visible + .toggle-track { outline:2px solid #7c6cff; outline-offset:2px; }
|
||||
.settings-toggle strong { display:block; color:#3e3956; font-size:.82rem; line-height:1.35; }
|
||||
.settings-toggle small { display:block; margin-top:.15rem; color:#8a849c; font-size:.7rem; line-height:1.35; }
|
||||
.settings-note { margin:.9rem 0 1rem; padding:.75rem .9rem; border:1px solid; border-radius:9px; font-size:.76rem; line-height:1.5; }
|
||||
.settings-note p + p { margin-top:.35rem; }
|
||||
.settings-note-info { background:#f1f7ff; border-color:#d7e8fa; color:#42627f; }
|
||||
.settings-note-warning { background:#fff8e8; border-color:#f1dfb4; color:#785f28; }
|
||||
.settings-note code { display:inline-block; margin:.2rem 0 0; padding:.12rem .35rem; border-radius:4px; background:rgba(255,255,255,.7); color:inherit; user-select:all; }
|
||||
.settings-subsection { margin-top:1.1rem; padding-top:1.1rem; border-top:1px solid #eeecf3; }
|
||||
.settings-subsection h3 { margin:0 0 .7rem; color:#514b69; font-size:.82rem; font-weight:800; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.settings-details { margin-top:1rem; border:1px solid #e8e5f2; border-radius:9px; background:#fcfbff; overflow:hidden; }
|
||||
.settings-details summary { display:flex; justify-content:space-between; align-items:center; padding:.7rem .85rem; cursor:pointer; color:#514b69; font-size:.78rem; font-weight:700; }
|
||||
.settings-details summary span { min-width:24px; padding:.05rem .4rem; border-radius:99px; background:#ece9ff; color:#6257bb; text-align:center; }
|
||||
.settings-details-body { padding:0 .85rem .85rem; }
|
||||
.settings-table-wrap { overflow-x:auto; }
|
||||
.settings-table-wrap .table { margin:0; background:transparent; font-size:.75rem; }
|
||||
.is-nowrap { white-space:nowrap; }
|
||||
.seo-preview { margin-top:.5rem; padding:.5rem .65rem; border:1px solid #ebe9ef; border-radius:7px; background:#fafafa; min-height:2rem; line-height:2; font-size:.78rem; }
|
||||
.settings-save-bar { position:sticky; bottom:.75rem; z-index:10; display:flex; justify-content:flex-end; padding:.65rem; border:1px solid rgba(222,218,238,.9); border-radius:12px; background:rgba(255,255,255,.92); box-shadow:0 8px 28px rgba(46,38,86,.12); backdrop-filter:blur(8px); }
|
||||
.settings-save-bar .button { min-width:150px; border-radius:8px; font-weight:700; }
|
||||
@media (max-width:700px) {
|
||||
.settings-grid, .settings-toggle-list { grid-template-columns:1fr; }
|
||||
.settings-field-wide { grid-column:auto; }
|
||||
.settings-section-head { grid-template-columns:36px minmax(0,1fr); padding:.9rem; }
|
||||
.settings-section-icon { width:36px; height:36px; }
|
||||
.settings-section-body { padding:.9rem; }
|
||||
.settings-save-bar { bottom:4.15rem; }
|
||||
.settings-save-bar .button { width:100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var colors = [
|
||||
|
||||
@@ -6,28 +6,18 @@
|
||||
<title>{{ t.nav_title }} — {{ t.setup_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
||||
.login-box { width: 100%; max-width: 400px; padding: 0 1rem; }
|
||||
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||
input, textarea, select, .input, .textarea, .select select {
|
||||
background-color: #fff !important; color: #333 !important; border-color: #dbdbdb !important;
|
||||
}
|
||||
.label, label { color: #363636 !important; }
|
||||
.notification { color: #333 !important; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ t.app_version() }}">
|
||||
</head>
|
||||
<body>
|
||||
<body class="auth-shell">
|
||||
<div class="login-box">
|
||||
<div class="has-text-right mb-3">
|
||||
<a href="?lang={{ lang.other().code() }}" class="has-text-grey is-size-7">{{ lang.other().label() }}</a>
|
||||
<div class="auth-language">
|
||||
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="has-text-centered mb-4">
|
||||
<p class="is-size-3">🐾</p>
|
||||
<h1 class="is-size-4 has-text-weight-bold">{{ t.nav_title }}</h1>
|
||||
<p class="has-text-grey">{{ t.setup_title }}</p>
|
||||
<div class="auth-head">
|
||||
<span class="auth-icon">🐾</span>
|
||||
<h1>{{ t.nav_title }}</h1>
|
||||
<p>{{ t.setup_title }}</p>
|
||||
</div>
|
||||
<p class="has-text-grey has-text-centered is-size-7 mb-4">{{ t.setup_description }}</p>
|
||||
{% if let Some(err) = error.as_ref() %}
|
||||
|
||||
@@ -9,13 +9,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Add form -->
|
||||
<div class="form-card" style="margin-bottom:1.5rem;">
|
||||
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:0.75rem;">{{ t.testimonials_add_title }}</h2>
|
||||
<form method="post" action="/admin/testimonials/add" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
<section class="admin-section mb-5">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">💬</span>
|
||||
<div><h2>{{ t.testimonials_add_title }}</h2></div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
<form method="post" action="/admin/testimonials/add" enctype="multipart/form-data" class="form-grid">
|
||||
<div class="field admin-field-wide">
|
||||
<label class="label">{{ t.testimonials_text }} *</label>
|
||||
<div class="control">
|
||||
<textarea class="input" name="text" rows="3" required style="min-height:80px;resize:vertical;"></textarea>
|
||||
<textarea class="textarea" name="text" rows="3" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -30,27 +34,28 @@
|
||||
<input class="input" type="file" name="image" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="button is-primary">{{ t.testimonials_add_button }}</button>
|
||||
<div class="admin-field-wide"><button type="submit" class="button is-primary">{{ t.testimonials_add_button }}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- List -->
|
||||
{% if testimonials.is_empty() %}
|
||||
<p style="color:#888;">{{ t.testimonials_empty }}</p>
|
||||
<p class="empty-state">{{ t.testimonials_empty }}</p>
|
||||
{% else %}
|
||||
{% for item in &testimonials %}
|
||||
<div class="item-card" id="card-{{ item.id.unwrap() }}">
|
||||
<!-- View mode -->
|
||||
<div class="tm-view" id="view-{{ item.id.unwrap() }}">
|
||||
<div class="item-card-header">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div class="testimonial-content">
|
||||
{% if let Some(image_url) = item.image_url.as_deref() %}
|
||||
<img src="{{ image_url }}" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
||||
<img src="{{ image_url }}" alt="" class="testimonial-avatar">
|
||||
{% endif %}
|
||||
<div>
|
||||
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
|
||||
<div class="testimonial-text">{{ item.text }}</div>
|
||||
{% if let Some(note) = item.author_note.as_deref() %}
|
||||
<div style="font-size:0.8rem;color:#888;margin-top:0.2rem;">{{ note }}</div>
|
||||
<div class="testimonial-note">{{ note }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,7 +84,7 @@
|
||||
<div class="field">
|
||||
<label class="label">{{ t.testimonials_text }}</label>
|
||||
<div class="control">
|
||||
<textarea class="input" name="text" rows="3" style="min-height:70px;resize:vertical;">{{ item.text }}</textarea>
|
||||
<textarea class="textarea" name="text" rows="3">{{ item.text }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -90,10 +95,12 @@
|
||||
</div>
|
||||
{% if item.image_path.is_some() %}
|
||||
<div class="field">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;">
|
||||
{% if let Some(image_url) = item.image_url.as_deref() %}<img src="{{ image_url }}" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">{% endif %}
|
||||
<label style="font-size:0.85rem;cursor:pointer;color:#888;">
|
||||
<input type="checkbox" name="remove_image" value="1"> {{ t.testimonials_remove_image }}
|
||||
<div class="testimonial-content mb-2">
|
||||
{% if let Some(image_url) = item.image_url.as_deref() %}<img src="{{ image_url }}" alt="" class="testimonial-avatar">{% endif %}
|
||||
<label class="admin-check admin-check-danger">
|
||||
<input type="checkbox" name="remove_image" value="1">
|
||||
<span class="admin-check-box">✓</span>
|
||||
<span>{{ t.testimonials_remove_image }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +111,7 @@
|
||||
<input class="input" type="file" name="image" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<div class="action-row">
|
||||
<button type="submit" class="button btn-sm is-primary">{{ t.testimonials_save }}</button>
|
||||
<button type="button" class="button btn-sm is-light" onclick="toggleEdit({{ item.id.unwrap() }})">✕</button>
|
||||
</div>
|
||||
|
||||
+38
-44
@@ -11,30 +11,29 @@
|
||||
{% for user in &users %}
|
||||
<div class="item-card">
|
||||
<div class="item-card-header">
|
||||
<span class="name">{{ user.login }}{% if let Some(dn) = user.display_name.as_deref() %} <span style="font-weight:400;color:#888;">— {{ dn }}</span>{% endif %}</span>
|
||||
<span class="name">{{ user.login }}{% if let Some(dn) = user.display_name.as_deref() %} <span class="muted-detail">— {{ dn }}</span>{% endif %}</span>
|
||||
<span class="badge badge-{{ user.status }}">{{ t.client_status(&user.status) }}</span>
|
||||
</div>
|
||||
<div class="item-card-meta">
|
||||
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
|
||||
</div>
|
||||
{% if user.status == "active" %}
|
||||
<form method="post" action="/admin/users/{{ user.id }}/telegram" style="margin-top:0.5rem; padding-top:0.5rem; border-top:1px solid #eee;">
|
||||
<div class="columns is-mobile is-vcentered" style="margin-bottom:0;">
|
||||
<div class="column">
|
||||
<div class="field" style="margin-bottom:0;">
|
||||
<label class="label is-small">{{ t.users_telegram_chat_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input is-small" type="text" name="telegram_chat_id" placeholder="123456789" value="{{ user.telegram_chat_id.as_deref().unwrap_or_default() }}">
|
||||
</div>
|
||||
<form method="post" action="/admin/users/{{ user.id }}/telegram" class="inline-admin-form">
|
||||
<div class="inline-admin-grid">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_telegram_chat_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input is-small" type="text" name="telegram_chat_id" placeholder="123456789" value="{{ user.telegram_chat_id.as_deref().unwrap_or_default() }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="checkbox is-small">
|
||||
<div>
|
||||
<label class="admin-toggle admin-toggle-compact">
|
||||
<input type="checkbox" name="telegram_notifications" value="true" {% if user.telegram_notifications == Some(true) %}checked{% endif %}>
|
||||
{{ t.users_telegram_enabled }}
|
||||
<span class="admin-toggle-track" aria-hidden="true"><span></span></span>
|
||||
<span><strong>{{ t.users_telegram_enabled }}</strong></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<div>
|
||||
<button type="submit" class="button is-small is-info is-outlined">💾</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,41 +53,36 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="form-card" style="margin-top:1.5rem;">
|
||||
<h2 class="is-size-5 has-text-weight-bold mb-3">{{ t.users_add_title }}</h2>
|
||||
<section class="admin-section mt-5">
|
||||
<header class="admin-section-head">
|
||||
<span class="admin-section-icon" aria-hidden="true">➕</span>
|
||||
<div><h2>{{ t.users_add_title }}</h2></div>
|
||||
</header>
|
||||
<div class="admin-section-body">
|
||||
{% if let Some(err) = error.as_ref() %}
|
||||
<div class="notification is-danger is-light">{{ err }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/users/add">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_login }}</label>
|
||||
<div class="control"><input class="input" type="text" name="login" required></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_display_name }}</label>
|
||||
<div class="control"><input class="input" type="text" name="display_name"></div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/users/add" class="form-grid">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_login }}</label>
|
||||
<div class="control"><input class="input" type="text" name="login" required></div>
|
||||
</div>
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_password }}</label>
|
||||
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_password_confirm }}</label>
|
||||
<div class="control"><input class="input" type="password" name="password_confirm" required minlength="4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_display_name }}</label>
|
||||
<div class="control"><input class="input" type="text" name="display_name"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_password }}</label>
|
||||
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">{{ t.users_password_confirm }}</label>
|
||||
<div class="control"><input class="input" type="password" name="password_confirm" required minlength="4"></div>
|
||||
</div>
|
||||
<div class="admin-field-wide">
|
||||
<button type="submit" class="button is-primary">{{ t.users_add_button }}</button>
|
||||
</div>
|
||||
<button type="submit" class="button is-primary">{{ t.users_add_button }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
+361
-45
@@ -19,16 +19,93 @@
|
||||
color: #2d2b55; line-height: 1.6; background: #f8f7ff;
|
||||
padding: 0 0 2rem;
|
||||
}
|
||||
body.portal-overlay-open { overflow: hidden; }
|
||||
.portal-header {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #7c6cff, #b06cff);
|
||||
color: #fff; padding: 2rem 1.5rem 1.5rem; text-align: center;
|
||||
position: relative; isolation: isolate; overflow: visible; min-height: 150px;
|
||||
display: grid; place-items: center; padding: 2rem 1.5rem 1.5rem;
|
||||
color: #2d2b55; text-align: center;
|
||||
background: rgba(255,255,255,.38);
|
||||
border-block: 1px solid rgba(124,108,255,.1);
|
||||
}
|
||||
.portal-header::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 18% 15%, rgba(255,255,255,.7), transparent 35%),
|
||||
linear-gradient(135deg, rgba(124,108,255,.06), rgba(176,108,255,.08));
|
||||
}
|
||||
.portal-header.has-media { color: #fff; }
|
||||
.portal-header.has-media::after {
|
||||
background: linear-gradient(135deg, rgba(30,25,65,.32), rgba(78,45,105,.2));
|
||||
}
|
||||
.portal-header-backdrop {
|
||||
position: absolute; inset: 0; z-index: 0; overflow: hidden; pointer-events: none;
|
||||
}
|
||||
.portal-header-content {
|
||||
position: absolute; left: 50%; top: 50%; z-index: 2;
|
||||
width: max-content; max-width: calc(100% - 2rem);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.portal-header.has-media .portal-header-content {
|
||||
padding: .5rem 1.25rem; border-radius: 14px;
|
||||
background: rgba(18,13,42,.62); box-shadow: 0 8px 32px rgba(18,13,42,.2);
|
||||
text-shadow: 0 2px 12px rgba(0,0,0,.55);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.header-media-grid {
|
||||
width: 100%; height: 100%; display: grid;
|
||||
grid-template-columns: repeat(var(--header-cols, 5), minmax(0, 1fr));
|
||||
grid-template-rows: repeat(var(--header-rows, 2), minmax(0, 1fr));
|
||||
gap: 3px; background: #d9d4ee; perspective: 900px;
|
||||
}
|
||||
.header-media-tile {
|
||||
position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #ddd8ed;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.header-media-tile[hidden] { display: none; }
|
||||
.header-media-tile.is-flipping { animation: header-tile-flip .8s ease-in-out; }
|
||||
.header-media-image { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.header-media-image.header-video-preview { position: absolute; max-width: none; object-fit: unset; }
|
||||
.header-media-image.header-video-preview:not(.is-fitted) { opacity: 0; }
|
||||
@keyframes header-tile-flip {
|
||||
0%, 100% { transform: rotateY(0deg); }
|
||||
49% { transform: rotateY(88deg); }
|
||||
50% { transform: rotateY(-88deg); }
|
||||
}
|
||||
.header-decorations { position: absolute; inset: 0; overflow: hidden; }
|
||||
.header-deco-item {
|
||||
position: absolute; width: 72px; height: 72px; opacity: .25;
|
||||
animation: header-icon-drift 58s ease-in-out infinite alternate;
|
||||
}
|
||||
.header-deco-item svg { width: 100%; height: 100%; animation: header-icon-spin 110s linear infinite; }
|
||||
.header-deco-item:nth-child(1) { left: 3%; top: 10%; color: #7c6cff; }
|
||||
.header-deco-item:nth-child(2) { left: 18%; bottom: -12%; width: 88px; height: 88px; color: #ff8c26; animation-duration: 71s; animation-delay: -18s; }
|
||||
.header-deco-item:nth-child(3) { left: 38%; top: -20%; width: 96px; height: 96px; color: #00a98f; animation-duration: 83s; animation-delay: -31s; }
|
||||
.header-deco-item:nth-child(4) { right: 35%; bottom: -28%; width: 104px; height: 104px; color: #b06cff; animation-duration: 67s; animation-delay: -9s; }
|
||||
.header-deco-item:nth-child(5) { right: 18%; top: 8%; width: 78px; height: 78px; color: #ff5287; animation-duration: 76s; animation-delay: -42s; }
|
||||
.header-deco-item:nth-child(6) { right: 3%; bottom: -5%; width: 92px; height: 92px; color: #4d90e8; animation-duration: 89s; animation-delay: -25s; }
|
||||
.header-deco-item:nth-child(even) svg { animation-direction: reverse; animation-duration: 135s; }
|
||||
@keyframes header-icon-drift {
|
||||
from { transform: translate3d(-8px, -4px, 0); }
|
||||
to { transform: translate3d(14px, 10px, 0); }
|
||||
}
|
||||
@keyframes header-icon-spin { to { transform: rotate(360deg); } }
|
||||
.portal-header h1 { font-size: 1.5rem; font-weight: 700; }
|
||||
.portal-header .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; }
|
||||
.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 {
|
||||
font-size: 1.15rem; font-weight: 700; margin: 1.5rem 0 0.75rem;
|
||||
padding-bottom: 0.4rem; border-bottom: 2px solid #ede7f6;
|
||||
@@ -119,9 +196,11 @@
|
||||
font-weight: 700; min-width: 5.5rem;
|
||||
}
|
||||
.upcoming-row .up-time { color: #7a7599; }
|
||||
.calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; }
|
||||
.calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; }
|
||||
.calendar-surface { background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; }
|
||||
.calendar-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .65rem; }
|
||||
.calendar-head button { border: 0; background: #f0edff; color: #6255c7; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; }
|
||||
.calendar-close { display: none; }
|
||||
.calendar-title { font-size: .95rem; font-weight: 700; }
|
||||
.calendar-weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; text-align: center; }
|
||||
.calendar-weekdays { color: #999; font-size: .68rem; margin-bottom: 3px; }
|
||||
@@ -131,20 +210,87 @@
|
||||
.calendar-day.future { background: #f3f3f3; color: #bbb; border: 1px dashed #ddd; }
|
||||
.calendar-month[hidden] { display: none; }
|
||||
.calendar-legend { margin-top: .65rem; font-size: .72rem; color: #999; }
|
||||
.calendar-notification-settings {
|
||||
position: relative; margin-top: .9rem; padding-top: .9rem;
|
||||
border-top: 1px solid #ece9f5;
|
||||
}
|
||||
.calendar-notification-settings:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
|
||||
.notification-setting-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: .75rem; margin-bottom: .65rem;
|
||||
}
|
||||
.notification-setting-title { font-size: .88rem; font-weight: 700; }
|
||||
.notification-info-wrap { position: relative; display: inline-flex; }
|
||||
.notification-info-button {
|
||||
display: grid; place-items: center; width: 24px; height: 24px;
|
||||
border: 1px solid #cfc9ed; border-radius: 50%; background: #f6f4ff;
|
||||
color: #6558c8; font: 700 .78rem/1 Georgia, serif; cursor: help;
|
||||
}
|
||||
.notification-info-button:focus-visible { outline: 3px solid rgba(124,108,255,.25); outline-offset: 2px; }
|
||||
.notification-help {
|
||||
display: none; position: absolute; right: 0; bottom: calc(100% + .55rem); z-index: 20;
|
||||
width: min(280px, calc(100vw - 3rem)); padding: .7rem .8rem; border-radius: 10px;
|
||||
color: #fff; background: #302b52; box-shadow: 0 12px 32px rgba(20,18,40,.28);
|
||||
font-size: .76rem; font-weight: 400; line-height: 1.45; text-align: left;
|
||||
}
|
||||
.notification-help::after {
|
||||
content: ""; position: absolute; right: 7px; top: 100%;
|
||||
border: 6px solid transparent; border-top-color: #302b52;
|
||||
}
|
||||
.notification-info-wrap:hover .notification-help,
|
||||
.notification-info-wrap:focus-within .notification-help,
|
||||
.notification-info-wrap.is-open .notification-help { display: block; }
|
||||
.notification-toggle {
|
||||
width: 100%; border: 0; border-radius: 9px; padding: .58rem .75rem;
|
||||
color: #fff; background: #7567e8; font: inherit; font-size: .8rem;
|
||||
font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.notification-toggle:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.notification-status { display: none; margin-top: .5rem; font-size: .74rem; line-height: 1.4; }
|
||||
.pagination { display: flex; justify-content: center; align-items: center; gap: .75rem; margin: 1rem 0; font-size: .85rem; }
|
||||
.pagination a { color: #6558c8; text-decoration: none; padding: .35rem .7rem; background: #fff; border: 1px solid #e5e1ff; border-radius: 8px; }
|
||||
.modal-bg { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(20,18,40,.55); align-items: center; justify-content: center; padding: 1rem; }
|
||||
.modal-bg.open { display: flex; }
|
||||
.notification-modal { width: min(420px, 100%); background: #fff; border-radius: 14px; padding: 1.2rem; box-shadow: 0 15px 50px rgba(0,0,0,.25); }
|
||||
.notification-modal h2 { font-size: 1.15rem; margin-bottom: .45rem; }
|
||||
.notification-modal p { font-size: .88rem; color: #777; }
|
||||
.notification-actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||
.notification-actions button { border: 0; border-radius: 8px; padding: .55rem .9rem; cursor: pointer; }
|
||||
.notification-primary { background: #7567e8; color: #fff; }
|
||||
@media (max-width: 800px) {
|
||||
.portal-header { min-height: 178px; padding: 2.2rem 4rem 1.8rem; }
|
||||
.header-deco-item { opacity: .2; }
|
||||
.portal-grid { display: flex; flex-direction: column; }
|
||||
.calendar-panel { position: static; order: -1; width: 100%; margin-top: 1rem; }
|
||||
.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>
|
||||
</head>
|
||||
@@ -154,10 +300,34 @@
|
||||
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
|
||||
</div>
|
||||
|
||||
<div class="portal-header">
|
||||
<h1>{{ t.portal_title }}</h1>
|
||||
<div class="sub">{{ client.name }}</div>
|
||||
{% if notifications_enabled %}<button class="portal-settings" type="button" onclick="openNotificationSettings()" aria-label="{{ t.portal_notifications }}">⚙</button>{% endif %}
|
||||
<div 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>
|
||||
<div class="sub">{{ client.name }}</div>
|
||||
</div>
|
||||
{% if show_portal_settings %}
|
||||
<button class="portal-settings mobile-only-settings" type="button" onclick="openPortalSettings()" aria-label="{{ t.portal_calendar }}" aria-expanded="false">📅</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
@@ -266,12 +436,15 @@
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
{% if !calendar_months.is_empty() %}
|
||||
<aside class="calendar-panel">
|
||||
{% if show_portal_settings %}
|
||||
<aside class="calendar-panel" id="calendarPanel" onclick="if(event.target===this) closePortalSettings()">
|
||||
<div class="calendar-surface">
|
||||
{% if !calendar_months.is_empty() %}
|
||||
<div class="calendar-head">
|
||||
<button type="button" onclick="moveCalendar(1)" aria-label="{{ t.portal_previous }}">‹</button>
|
||||
<span class="calendar-title" id="calendarTitle">{{ t.portal_calendar }}</span>
|
||||
<button type="button" onclick="moveCalendar(-1)" aria-label="{{ t.portal_next }}">›</button>
|
||||
<button class="calendar-close" type="button" onclick="closePortalSettings()" aria-label="{{ t.portal_settings }}">×</button>
|
||||
</div>
|
||||
<div class="calendar-weekdays"><span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span></div>
|
||||
{% for month in &calendar_months %}
|
||||
@@ -289,27 +462,171 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="calendar-legend">{{ t.portal_future_visit }} — ···</div>
|
||||
{% else %}
|
||||
<div class="calendar-head">
|
||||
<span class="calendar-title">{{ t.portal_settings }}</span>
|
||||
<button class="calendar-close" type="button" onclick="closePortalSettings()" aria-label="{{ t.portal_settings }}">×</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if notifications_enabled %}
|
||||
<section class="calendar-notification-settings" aria-labelledby="notificationSettingTitle">
|
||||
<div class="notification-setting-head">
|
||||
<h2 class="notification-setting-title" id="notificationSettingTitle">{{ t.portal_notifications }}</h2>
|
||||
<span class="notification-info-wrap">
|
||||
<button class="notification-info-button" type="button" onclick="toggleNotificationHelp(event)" aria-label="{{ t.portal_notifications_text }}" aria-expanded="false" aria-describedby="notificationHelp">i</button>
|
||||
<span class="notification-help" id="notificationHelp" role="tooltip">{{ t.portal_notifications_text }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="notification-toggle" type="button" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
|
||||
<p class="notification-status" id="notificationStatus"></p>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if notifications_enabled %}
|
||||
<div class="modal-bg" id="notificationModal" onclick="if(event.target===this) closeNotificationSettings()">
|
||||
<div class="notification-modal">
|
||||
<h2>{{ t.portal_notifications }}</h2>
|
||||
<p id="notificationText">{{ t.portal_notifications_text }}</p>
|
||||
<p id="notificationStatus" style="display:none;margin-top:0.65rem;font-weight:600;"></p>
|
||||
<div class="notification-actions">
|
||||
<button type="button" class="notification-primary" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
|
||||
<button type="button" onclick="closeNotificationSettings()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
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) {
|
||||
document.getElementById('fb-view-' + id).style.display = 'none';
|
||||
document.getElementById('fb-form-' + id).style.display = '';
|
||||
@@ -349,7 +666,7 @@ renderCalendar();
|
||||
}
|
||||
function showStatus(message, error) {
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
status.style.display = 'block';
|
||||
status.style.color = error ? '#b42318' : '#067647';
|
||||
}
|
||||
async function saveSubscription(value) {
|
||||
@@ -386,14 +703,6 @@ renderCalendar();
|
||||
}
|
||||
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
|
||||
}
|
||||
window.openNotificationSettings = function() {
|
||||
document.getElementById('notificationModal').classList.add('open');
|
||||
refresh().catch(function(error) {
|
||||
console.error(error);
|
||||
showStatus('{{ t.portal_notifications_error }}', true);
|
||||
});
|
||||
};
|
||||
window.closeNotificationSettings = function() { document.getElementById('notificationModal').classList.remove('open'); };
|
||||
toggle.addEventListener('click', async function() {
|
||||
toggle.disabled = true;
|
||||
try {
|
||||
@@ -405,10 +714,11 @@ renderCalendar();
|
||||
});
|
||||
await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
status.style.display = 'none';
|
||||
} else {
|
||||
var permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
document.getElementById('notificationText').textContent = '{{ t.portal_notifications_denied }}';
|
||||
showStatus('{{ t.portal_notifications_denied }}', true);
|
||||
return;
|
||||
}
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
@@ -432,7 +742,13 @@ renderCalendar();
|
||||
});
|
||||
})();
|
||||
{% endif %}
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key !== 'Escape') return;
|
||||
closeNotificationHelp();
|
||||
closePortalSettings();
|
||||
});
|
||||
</script>
|
||||
{% include "partials/lightbox.html" %}
|
||||
{% include "partials/cookie_banner.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -569,5 +569,6 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% include "partials/cookie_banner.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<div class="cookie-banner" id="cookieBanner">
|
||||
<p class="cookie-banner-text">
|
||||
{{ t.cookie_banner_text }}
|
||||
<a href="/privacy?lang={{ lang.code() }}">{{ t.cookie_privacy_link }}</a>
|
||||
</p>
|
||||
<button type="button" class="cookie-banner-accept" id="cookieAccept">{{ t.cookie_banner_accept }}</button>
|
||||
</div>
|
||||
<style>
|
||||
.cookie-banner {
|
||||
display: none;
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 300;
|
||||
flex-wrap: wrap; gap: .75rem 1.25rem; align-items: center; justify-content: center;
|
||||
padding: .85rem 1.1rem;
|
||||
background: rgba(26, 26, 46, .95); color: #fff;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, .18);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.cookie-banner.is-open { display: flex; }
|
||||
.cookie-banner-text { margin: 0; font-size: .88rem; line-height: 1.45; max-width: 720px; }
|
||||
.cookie-banner-text a { color: #b9b2ff; text-decoration: underline; }
|
||||
.cookie-banner-accept {
|
||||
flex: 0 0 auto; border: none; cursor: pointer;
|
||||
padding: .6rem 1.6rem; border-radius: 10px;
|
||||
background: #6c63ff; color: #fff; font-weight: 700; font-size: .9rem;
|
||||
transition: background .18s, transform .18s;
|
||||
}
|
||||
.cookie-banner-accept:hover { background: #5a52d5; transform: translateY(-1px); }
|
||||
@media (max-width: 600px) {
|
||||
.cookie-banner.is-open { flex-direction: column; text-align: center; padding: 1rem; gap: .7rem; }
|
||||
.cookie-banner-accept { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var KEY = 'cookie_consent';
|
||||
var banner = document.getElementById('cookieBanner');
|
||||
if (!banner) { return; }
|
||||
var accepted;
|
||||
try { accepted = window.localStorage.getItem(KEY) === '1'; } catch (e) { accepted = false; }
|
||||
if (accepted) { return; }
|
||||
banner.classList.add('is-open');
|
||||
document.getElementById('cookieAccept').addEventListener('click', function () {
|
||||
try { window.localStorage.setItem(KEY, '1'); } catch (e) {}
|
||||
banner.classList.remove('is-open');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ lang.code() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{{ t.nav_title }} — {{ t.privacy_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
color: #1a1a2e; background: linear-gradient(170deg, #f0eeff 0%, #fff 60%);
|
||||
min-height: 100vh; padding: 2rem 1rem;
|
||||
}
|
||||
.doc {
|
||||
max-width: 760px; margin: 0 auto; background: #fff; border-radius: 18px;
|
||||
border: 1px solid #e8e6ff; box-shadow: 0 8px 40px rgba(108,99,255,0.08);
|
||||
padding: 2.4rem 2rem;
|
||||
}
|
||||
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 1.2rem; line-height: 1.25; }
|
||||
h2 { font-size: 1.1rem; font-weight: 700; margin: 1.6rem 0 .5rem; }
|
||||
p, li { color: #444; font-size: .98rem; line-height: 1.6; }
|
||||
ul { padding-left: 1.3rem; margin: .3rem 0; }
|
||||
li { margin: .25rem 0; }
|
||||
.muted { color: #888; font-size: .85rem; margin-top: 1.8rem; }
|
||||
.back-link {
|
||||
display: inline-block; margin-top: 1.8rem; padding: .7rem 1.8rem; border-radius: 12px;
|
||||
background: #6c63ff; color: #fff; font-weight: 700; text-decoration: none;
|
||||
transition: background .2s, transform .2s;
|
||||
}
|
||||
.back-link:hover { background: #5a52d5; transform: translateY(-1px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<h1>{{ t.privacy_title }}</h1>
|
||||
|
||||
{% if lang.code() == "ru" %}
|
||||
<p>Настоящая политика описывает, какие данные обрабатывает сайт «{{ t.nav_title }}» и как используются файлы cookie. Продолжая пользоваться сайтом, вы соглашаетесь с условиями, изложенными ниже.</p>
|
||||
|
||||
<h2>Какие данные мы собираем</h2>
|
||||
<ul>
|
||||
<li><strong>Данные из формы заявки</strong> — имя, телефон и комментарий, которые вы указываете добровольно, чтобы мы могли связаться с вами по поводу услуги.</li>
|
||||
<li><strong>Файлы cookie и техническая информация</strong> — см. раздел ниже. IP-адреса мы не храним.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Какие файлы cookie используются</h2>
|
||||
<ul>
|
||||
<li><strong>Языковая настройка</strong> — запоминает выбранный язык интерфейса.</li>
|
||||
<li><strong>Сессия</strong> — необходима для входа администратора в панель управления.</li>
|
||||
<li><strong>Статистика посещений</strong> — анонимный идентификатор, который помогает считать число уникальных посещений и просмотров. Он не содержит персональных данных, не передаётся третьим лицам и не используется для рекламы.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Цели обработки</h2>
|
||||
<p>Связь с вами по оставленной заявке, оказание услуг по уходу за животными, а также улучшение работы сайта на основе обезличенной статистики.</p>
|
||||
|
||||
<h2>Передача третьим лицам</h2>
|
||||
<p>Мы не продаём и не передаём ваши данные третьим лицам, за исключением случаев, предусмотренных законодательством.</p>
|
||||
|
||||
<h2>Ваши права</h2>
|
||||
<p>Вы можете запросить сведения об обработке ваших данных, их изменение или удаление, а также отозвать согласие, связавшись с нами по контактам, указанным на сайте. Файлы cookie можно отключить в настройках вашего браузера.</p>
|
||||
|
||||
<p class="muted">Дата последнего обновления: 2026. Оператор может уточнить реквизиты и контактные данные в этом документе.</p>
|
||||
{% else %}
|
||||
<p>This policy explains what data the “{{ t.nav_title }}” website processes and how cookies are used. By continuing to use the site, you agree to the terms set out below.</p>
|
||||
|
||||
<h2>What data we collect</h2>
|
||||
<ul>
|
||||
<li><strong>Request form data</strong> — the name, phone number and comment you voluntarily provide so we can contact you about the service.</li>
|
||||
<li><strong>Cookies and technical information</strong> — see the section below. We do not store IP addresses.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Cookies we use</h2>
|
||||
<ul>
|
||||
<li><strong>Language preference</strong> — remembers your selected interface language.</li>
|
||||
<li><strong>Session</strong> — required for the administrator to sign in to the control panel.</li>
|
||||
<li><strong>Visit statistics</strong> — an anonymous identifier that helps count unique visits and views. It contains no personal data, is not shared with third parties, and is not used for advertising.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Purposes of processing</h2>
|
||||
<p>Contacting you about your request, providing pet-sitting services, and improving the website based on anonymised statistics.</p>
|
||||
|
||||
<h2>Sharing with third parties</h2>
|
||||
<p>We do not sell or share your data with third parties, except as required by law.</p>
|
||||
|
||||
<h2>Your rights</h2>
|
||||
<p>You may request information about the processing of your data, its correction or deletion, and withdraw your consent by contacting us via the details provided on the site. Cookies can be disabled in your browser settings.</p>
|
||||
|
||||
<p class="muted">Last updated: 2026. The operator may add legal and contact details to this document.</p>
|
||||
{% endif %}
|
||||
|
||||
<a href="/?lang={{ lang.code() }}" class="back-link">{{ t.landing_thank_you_back }}</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -37,5 +37,6 @@
|
||||
<p>{{ t.landing_thank_you_text }}</p>
|
||||
<a href="/?lang={{ lang.code() }}" class="back-link">{{ t.landing_thank_you_back }}</a>
|
||||
</div>
|
||||
{% include "partials/cookie_banner.html" %}
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user